---
title: Lazy content load (instant cold-mount + truly lazy N-actions widget)
---

# Lazy content load

## What it is

### What it does

When you open a Claude Code session in Omniscio — switching to it in the sidebar, clicking it from search, or returning to it after a window hide — the chat panel paints visible content within a few hundred milliseconds, no matter how many messages or tool calls the session contains. A 4,000-message tool-heavy thread feels the same as a 20-message one.

Inside every agent turn, the per-turn "Actions" widget (the collapsible pill that hides the agent's tool calls and tool results) is now **truly lazy**: at cold mount the widget's content is **not** in memory at all. Clicking the pill fires a one-off fetch for just that turn's tool activity, parses it, and renders the expanded view. Closing the pill releases the content. If you never click, that content never gets paid for.

Together these two changes shrink the cold-mount payload by roughly 10×–100× on tool-heavy sessions (the exact ratio is the size of tool-output bytes divided by real-prose bytes per turn), without changing what the expanded activity view looks like once you do click.

## Where to find it

**Settings → Performance**, as the lazy-content-load toggle. Its effect is felt everywhere a session opens — switching to it in the sidebar, clicking it from search, or returning to it after the window was hidden.

## How it behaves

### What the user sees

- Switching to a long session is visibly instant. There's no multi-second freeze, no progress shimmer, no "loading messages" pill at the top of the panel — the content just appears.
- The "Actions" pill (one per agent turn that called any tools) looks identical to before: same icon, same label `Actions`, same chevron.
- The first click on a pill is the same fast expand it always was — the round-trip to fetch that turn's content is short enough to feel like a normal toggle. Subsequent re-expand of the same pill fires the fetch again (no renderer cache in v1, by design — re-expand is rare).
- Search across long sessions still works. If a search hit lands on a tool-output line inside a not-yet-expanded turn, Omniscio auto-expands that turn before scrolling, so the highlighted hit is visible when you arrive.
- Nothing changes on export, share-publish, or audit-export. Those paths still get the full message content.

### What changed under the hood

Before this change, Omniscio's cold-mount SELECT projected every column of `conversation_messages`, including the `content TEXT` column. On a tool-heavy turn that column is hundreds of KB to several MB — tool-call markers, tool-result blocks, embedded JSON. Every cold mount paid the cost of marshalling, JSON-parsing, and content-parsing that whole blob for every visible row, just to render a per-turn collapsed pill the user usually never expanded.

After this change there are two new columns on `conversation_messages` and a new IPC channel that swaps in a "lite" cold-mount path:

- **`summary_prose TEXT`** — the marker-stripped, prose-only body for an agent turn. NULL means "not yet derived" (a fallback signal, not an error).
- **`has_tool_activity INTEGER NOT NULL DEFAULT 0`** — 1 iff the original `content` contained any tool-call lines, so the renderer knows whether to draw the Actions pill at all.

Cold-mount now calls `getSessionHistoryLite()` (a separate function from the original `getSessionHistory()` — a deliberate function-pair, not a boolean flag, so the TypeScript compiler enforces correct consumption per call site). The lite SELECT projects everything except `content`, and ships rows where `content` has been replaced by `summary_prose` and a small `metadata.liteContent` marker that tells the renderer where to fetch the full content if it needs to.

When the user clicks an Actions pill — or when a search hit lands inside a not-yet-expanded turn — the renderer fires a new IPC channel `SESSION_GET_TURN_CONTENT { messageId }` that returns just that one row's full content. The renderer parses it with the existing `parseContentSegments` and renders the expanded view inline.

The full-content path is still alive and unchanged for export, share-publish, audit-export, and FTS. They keep calling `getSessionHistory()` directly.

### Subagent-result rows defer their output too (2026-05-26)

Agent turns are not the only heavy rows. When a session spawns a subagent (the Task tool), the subagent's full result comes back as its own row — a **system** row tagged `metadata.kind = "subagent-result"`, holding up to 32 KB of the subagent's output (the cap is `SUBAGENT_RESULT_PREVIEW_MAX_CHARS`). In the chat these render as a collapsed "subagent result" card with a **Show output** toggle (auto-expanded only when the subagent errored). A session that fanned out a lot of subagent work can carry dozens of these rows, and because the original lite strip only touched _agent_ rows, every one of those 32 KB blobs still shipped on cold mount. On heavy sessions that was 90–98% of the whole cold-mount payload — the concrete cause of the "switching to the next session takes 4+ seconds" lag this whole architecture exists to kill.

So the lite cold-mount now strips subagent-result content the same way it strips agent content: the cold-mount SELECT replaces the body of any `subagent-result` system row with an empty string, and the row ships with a `metadata.subagentContentDeferred = true` marker instead of its output. All the _small_ metadata the card needs to render its collapsed header — the subagent's description, how long it ran, whether it errored, and the original byte size (`contentSize`) — is already in `metadata` and rides along untouched, so the collapsed card looks exactly the same as before.

When you click **Show output** (or when an errored subagent card auto-expands at mount), the card fires the very same `SESSION_GET_TURN_CONTENT { messageId }` IPC the Actions pill uses — it's source-agnostic, fetching one row's full content by ID regardless of whether that row is an agent turn or a system subagent-result — shows a brief "Loading output…" line, then renders the real output inline. The truncated-size hint under the output ("13 of 50,000 chars (truncated)") is computed from the original `contentSize` in metadata, so it reads correctly even before the body has been fetched. If the fetch fails, the card shows "(no output)" rather than an error. Subagent-result rows that are _not_ deferred (the kill-switch / full-content path) keep rendering their output inline with no IPC, exactly as before.

This strip is gated by the same `AMC_DISABLE_LITE_COLD_MOUNT=1` kill switch — with lite off, subagent-result rows ship full content and render inline with no fetch. Export, share, audit, and FTS are unaffected (they never went through the lite path).

### How content gets into the two new columns

There are three paths (one now dormant), in order of importance:

1. **Finalize-time derive on new rows.** When an agent turn completes (NDJSON stream finalize, or `/compact` post-summary turn write), the main process runs a small helper `extractProse(content)` and binds both columns into the same INSERT. Fail-open: if the helper throws, the INSERT lands with NULL `summary_prose` and 0 `has_tool_activity`, and the renderer falls back to fetching full content for that row. The streaming-checkpoint path (partial content flushed mid-turn) deliberately does NOT derive — its content isn't final, so any derived value would be stale.

   **Producer-side rescue (the trailing-bookkeeping-tool case).** `extractProse` is trailing-only — it resets its prose buffer on every top-level tool marker (`▸ `). So when an agent writes its real final answer and _then_ a trailing bookkeeping tool call (a `▸ TaskUpdate`, a final `▸ Bash` commit to save its work), the flattened `content` ends on that marker, the walker's buffer resets to empty, and the derive returns empty prose for a turn that genuinely _had_ an answer. Left alone, the lite classifier reads "empty prose + tool activity" as a pure-tool turn and folds the real answer into a collapsed "N actions" activity pill — the answer effectively vanishes. No content-only walker can repair this, because once flattened, "answer before bookkeeping tool" is byte-identical to "intermediate narration before bookkeeping tool." The producer, however, still holds the structure the flattening destroyed, so at finalize it passes the turn's last real answer block — `lastRealAssistantText`, the last text block that passed `isRealMessageProse` while the turn streamed — into `addMessage` as a `rescueProse` argument. `addMessage` promotes it into `summary_prose` only when all three gates pass: the derive came back empty, the row actually carries non-thinking activity (`has_non_thinking_activity === 1`), and the rescue text itself passes `isRealMessageProse`. The `extractProse` parser stays pure and untouched — the fix lives entirely in the producer — and the rescue is forward-only: it fixes new rows as they are written, with no migration to backfill rows buried before the fix shipped. On a derive throw the gate fails closed (prose stays NULL, activity flag 0), so the row fails open to full-content fetch exactly as before. See the trailing-bookkeeping-tool-erases-prose postmortem in the repo.

   **This is now the only live derive path.**

2. **Idle backfill of existing rows — DORMANT (unwired 2026-05-24).** A background service (`summary-prose-backfill.ts` + its scheduler) can walk every pre-existing agent row in batches and derive both columns, but **nothing in production triggers it anymore.** It used to drain on the main window's `blur` event; that trigger was removed because the backfill had no guard against in-flight (`metadata.partial = 1`) rows — it summarized truncated mid-stream checkpoints and stored `''`, which the lite path then painted as an empty shell over still-streaming content (the collapse-to-"Generating…" bug: messages flash in on click, then vanish; reported 2026-05-24, recurred on every window blur). The module is kept (not deleted) so a future re-enable _with a partial-row guard_ is cheap, but as of now the only way a pre-existing NULL row shows content is the renderer's NULL → full-content fallback. Migration **v231** NULLed the rows the backfill had already corrupted. See the partial-row backfill postmortem in the repo.
3. **UPDATE-time re-derive.** Any path that mutates an existing row's `content` (operator edit, compact-summary write, session-restore) re-derives both columns in the same UPDATE.

A NULL `summary_prose` is the "not yet derived" signal, and what the lite read path does with it depends on whether the turn is **settled** or still **partial**:

- **Settled row, NULL prose** (a never-finalized-but-ended turn, a pre-v218 row, or one the v227/v231/v235 migrations NULLed): the lite read path (`mapMessageLite`) runs `extractProse` over the raw `content` _at read time_, ships the trailing prose in place of `content`, and stamps the `liteContent` marker — so the row still gets the payload shrink even though nothing is stored in `summary_prose`. This read-time derive is what stops old archived sessions from shipping multi-MB raw tool output on cold mount (it was added in the load-old-archived-session fix, 2026-05-25).
- **Partial row** (in-flight or interrupted, `metadata.partial === true`): both read paths — `mapMessageLite` and the renderer's `bridgeLiteMessage` — short-circuit and serve the full `content` verbatim, **skipping the read-time derive entirely**. A partial row's stored content is a mid-stream checkpoint; running the trailing-only slice over it would hide the agent's live narration (or return empty mid-tool-block), and the renderer's append-on-delta cache would then lose the original prose permanently. The short-circuit mirrors the producer-side guard that already leaves `summary_prose` NULL on partial rows. See the lite-cold-mount partial-row-strip postmortem in the repo.

### Why the function-pair shape, not a flag

The first version of this plan used `getSessionHistory({ lite: boolean })`. It got rejected at red-team review because a boolean flag forces every caller to handle a union return type — and TypeScript can only enforce that union if every call site checks the flag, which is lint-fragile and easy to drift. A separate `getSessionHistoryLite()` function with a separate return type (`LiteConversationMessage`) makes the compiler the enforcer: if you call `getSessionHistoryLite()` and try to read `.content`, you get a type error, full stop.

This matters because there are exactly five cache-warmers that must opt into lite mode together (cold-mount panel fetch, Dashboard adjacent-prefetch, SidebarSessionRow hover, ArchiveView hover, mobile-prefetch streaming), and there are several other call sites (export, share, audit, FTS, full-content cold-mount when a search-hit auto-expands) that must NOT. The function-pair enforces the split at compile time; a CI lint test ([tests/unit/lint/lite-warmer-coverage.test.ts](../../tests/unit/lint/lite-warmer-coverage.test.ts)) pins which side each call site sits on so future refactors can't silently move one across the boundary.

### Mobile is fine too

Unlike the `/compact`-clamp (which is desktop-only because its post-compact branch can return multi-MB payloads that crash mobile `JSON.parse`), lite mode is desktop-AND-mobile safe. It always shrinks the payload, never grows it. The long-session-mobile-blank bug that the compact-clamp had to carve out for is, with lite mode, addressed at the source — the cold-mount payload is small to start with, so the JSON parse doesn't OOM the tab.

**As of 2026-05-24, mobile cold-mount actively uses lite + 200-row bounded** (was 2-row + full-content). The per-row shrink (KB-MB → hundreds of bytes) is what makes a 200-row Tailscale-Funnel cold-mount tolerable; the 2-row cap that lite was originally written around is gone. Mobile users now see the full real conversation on cold-mount, same as desktop. The shape lives in [`pickColdMountFetchShape`](../../src/renderer/src/lib/cold-mount-fetch-shape.ts) (mobile branch) and is pinned by `mobile-shape` in [`cache-warmer-shape-contract.md`](../../.claude/memory/contracts/cache-warmer-shape-contract.md). `compactClamp` is still off on mobile — the post-compact branch is the one path even lite can't tame.

That said, mobile inherits the same per-row content fetch when expanding a pill. On a slow connection that adds a one-time round-trip per first expand. Acceptable cost — the user is already opted into mobile's "explicit click to expand" model, and the alternative (pre-loading content the user usually doesn't click) is what crashed the tab in the first place.

### Bootstrap-LAM seed (mobile cold-mount blank-screen kill)

On mobile, even the trimmed lite cold-mount round-trip takes 1–3 s over Tailscale Funnel (longer when the single main thread is saturated under a many-session storm), so tapping a session with no warmed cache used to land on an empty panel with a spinner until the lite fetch resolved. To kill that blank-screen window, the **`WEB_BOOTSTRAP`** payload ships a `lastAgentMessageContent` field for two status groups: **inbox-attention** sessions (`needs_you` / `error` / `stalled`) carry the final agent message's prose (`summary_prose` preferred, `content` fallback, capped at ~50 KB); **live working** sessions (`running` / `starting`) carry the last _settled_ agent message (`summary_prose` only, gated on `summary_prose IS NOT NULL` so the in-flight partial row is skipped — a streaming session never seeds a raw half-stream — capped tighter at ~4 KB since many can ship at once). Both exclude asides. (Running/starting seeding shipped 2026-06-16 to fix a "running session takes ~30 s to load on mobile" report — the session was tiny; the wait was the missing seed plus main-thread contention, not payload size.) The renderer builds a synthetic agent `MessageBubble` from that field and renders it the instant `messages.length === 0 && lastAgentMessageContent` — before any lite fetch has even started. When the real lite history resolves, the normal virtual list takes over and the seed disappears naturally. No cache poke, no merge logic. The per-session LAM lookup is served by **`idx_messages_session_source_ts`** (`session_id, source, timestamp`); without that covering index the `source='agent' … ORDER BY timestamp` subquery fell into a per-session **TEMP B-TREE** sort that ballooned `WEB_BOOTSTRAP` to 0.7–1.5 s at ~126 active sessions (fixed 2026-06-24, migration `20260624090438` — see the [mobile-bootstrap-lam-temp-btree postmortem](../../.claude/memory/postmortems/mobile-bootstrap-lam-temp-btree-postmortem.md)). Full bootstrap-payload spec + Zod compat shape: [mobile-remote-access.md § Bootstrap LAM](mobile-remote-access.md#bootstrap-lam-last-agent-message-seed).

#### Live delivery — the seed also rides the attention-transition push (2026-07-31)

The `WEB_BOOTSTRAP` seed above only covers sessions that were already inbox-attention **at bootstrap**. A session that transitions **into** an attention status while the app is open — the #1 inbox scenario (an agent finishes / asks a question and pings you) — carried NO seed, because the live `SESSION_STATUS_CHANGED` push is a status delta and the renderer's `patchSession` never sets the field. So tapping that inbox card raced a cold lite fetch and felt slow (the "mobile is loading the first message really slowly" report). Fix: the seed now ALSO rides the **mobile-bound** `SESSION_STATUS_CHANGED` push for an attention transition. `enrichSessionStatusPushForRemote` ([web-access-session-seed-enrich.ts](../../src/main/services/web/web-access-session-seed-enrich.ts)) attaches `lastAgentMessageContent` at the **shared main-side remote-projection point BOTH web-access transports pass through** — the in-main forwarder AND the off-loop worker relay (the worker has NO DB, so the seed MUST be read main-side before `postMessage`; enriching only the forwarder would miss the worker transport). It is bounded + freeze-safe: a single indexed-row read (`getSessionSeedContent` — the bootstrap LAM attention subquery factored per-session), attention-status-only, capped at `LAM_LIVE_SEED_CAP_CHARS`, wrapped in try/catch (a DB hiccup never breaks the push), behind kill switch `AMC_DISABLE_LIVE_SEED_PUSH`. The optional `lastAgentMessageContent` field is declared on `sessionStatusChangedPayload` (else the renderer's `parseSessionStatusChangedPayload` safeParse strips it); `useSessionSync` patches it onto the session via `updateSessionLastAgentMessageContent` **before** `updateSessionStatus`, and the existing `bootstrapSeedMessage` render path paints it. Desktop's Electron push path never reaches the projection → untouched. **Worker-gated (2026-08-11):** `enrichSessionStatusPushForRemote` skips the seed read when `isDbWorkerActive()`. The read is a warm-mmap optimization; with the off-thread DB worker ON, main's `conversation_messages` mmap is COLD, so this per-push sync read hard-faulted it back off the disk → multi-second STARVED UI-thread freezes (up to ~74 s on a slow-DB-disk box under attention churn). With the worker on it degrades to the on-tap lite fetch; the worker is OFF by default, so only a worker-enabled box loses the instant seed (and it never freezes). See [db-worker-offload-contract.md](../../.claude/memory/contracts/db-worker-offload-contract.md).

### What didn't change

- Search index (`conversation_messages_fts`) is **curated, not full-content**: `indexableSearchText()` ([src/main/db/fts-index-text.ts](../../src/main/db/fts-index-text.ts)) indexes operator rows verbatim, agent rows as ONLY their trailing final prose (`extractProse` — mid-turn narration / tool-call / tool-result lines excluded), and partial + system rows not at all. Migration **v244** rebuilt the whole index under this per-source rule. So a search hit can no longer land on a tool-output line; it lands on operator text or an agent's final prose.
- The visual / interaction design of `ToolActivityBlock` once expanded is identical — same per-step rendering, same icons, same wrapping.
- The "Load older messages" pill's machinery at the top of long sessions still works the same way under the hood (extends the window by 200 rows per click on desktop) — but the pill JSX itself is disabled by default for every user as of 2026-05-21 via the `LOAD_OLDER_MESSAGES_PILL_ENABLED` code-level flag in `SessionPanel.tsx`, so it does not actually render. See [load-older-pill-disabled postmortem](../../.claude/memory/postmortems/load-older-pill-disabled-postmortem.md).
- The `/compact`-clamp on cold mount still applies — lite mode is layered on top of it, not a replacement. A clamped lite cold mount on a settled-compact session returns the since-compact rows in lite shape.
- Export to markdown, the share-publish flow, the audit-export, and any other caller that legitimately needs the full content keeps calling `getSessionHistory()` directly. No changes there.

### The narration-leak rescue (2026-05-21, removed 2026-05-24)

**Status: REMOVED.** The renderer-side rescue (`splitLiteProseByHeuristic`) and its sibling `mergeFinalMessageMetadata` synthesis were both deleted on 2026-05-24 once the schema v227 `summary_prose` re-derive landed and the rev-13 merged-bubble layout reshaped where tool activity surfaces. The history below is kept for context — the policy now is: **if the final bubble looks wrong, fix `extractProse` and ship a v228+ re-derive, not a render-time patch.** See `.claude/memory/contracts/summary-prose-contract.md` § "No renderer-side rescue".

— Original history —

The first day lite mode shipped, agent messages with tool activity started rendering with the model's pre-amble — "Reading the plan…", "Yes, that's right…", brief recaps — visible inline above the final answer. Before lite mode, that text was hidden inside the collapsed Actions pill alongside the tool calls, and only the final answer rendered inline. The regression made every long answer look like a stream-of-consciousness dump.

The cause was mechanical, not a model-behavior change. `extractProse(content)` strips tool-call (`▸ `) and tool-result (`← `) markers when it builds `summary_prose`, so by the time the renderer's `parseContentSegments` saw the lite body there were no marker boundaries left — the whole turn became one giant prose segment. `computeActivityOverview` (the splitter that normally decides "this goes in the pill, that renders inline") needed markers to find the boundary. Without them it put everything in `trailingSegments`, and the leading narration painted inline.

The original fix was a renderer-only paragraph-walker (`splitLiteProseByHeuristic`) that scanned for the first paragraph passing `isRealMessageProse` (Heuristic D) and hid everything above it. It worked for the cases it was designed for, but it had two ongoing failure modes: (1) a paragraph that _should_ have been hidden but didn't pass the heuristic would leak through; (2) the rescue silently dropped intermediate paragraphs the user wanted to read (the 2026-05-22 last-paragraph-fallback truncation regression). The fix superseded the rescue: the schema v227 migration now re-derives `summary_prose` from raw content for every `has_tool_activity = 1` row using the same trailing-only contract as `extractProse`, so the renderer no longer needs to guess. Under rev-13's merged-bubble layout the activity bubble above the merged bubble carries any leaked narration anyway — so even if a paragraph survives, the duplication is far less bad than truncation.

Kill switch fully removed: `AMC_DISABLE_NARRATION_RESCUE` was a no-op since the schema v227 re-derive; its preload deprecation warning was removed 2026-07-06, so the flag is gone entirely. `AMC_DISABLE_LITE_COLD_MOUNT=1` still routes lite back to full.

### Scroll-engine timing relationship (2026-05-23, SHIPPED; mobile reliability follow-up 2026-06-18)

Because lite cold-mount paints the message bubbles before the lazy `summary_prose` placeholders have grown into their final rendered prose, there's a brief window where the scroll engine can measure the panel's `scrollHeight` against the _pre-expansion_ layout. On a session whose final agent reply overflows the viewport in its final form but appears to fit during that window, the engine can pick the wrong scroll target — and on re-entry to a long session, the saved-position fingerprint can be computed against a different row count than the one used when the position was saved.

The fix work lives entirely in the scroll layer, not in the lite path: a new inner ref on the merged-turn bubble so the engine can target the agent's prose rather than the activity-toggle header, a late-render re-classification that re-measures once the real heights arrive, and a fingerprint that counts main-thread agent messages instead of raw row count. None of this changes how lite mode itself works; the `AMC_DISABLE_LITE_COLD_MOUNT=1` kill switch is still the panic button for the underlying lite-mode behaviour.

**Shipped 2026-05-23** (commit `c86d8b79a4`): all three fixes landed on master. They guarantee the scroll engine ENDS at the correct position once content settles.

**Mobile reliability follow-up (2026-06-18).** The May late-re-classification trigger was a `ResizeObserver` wired only when the merged-turn prose ref is attached. On a fresh **mobile** cold-open that ref is frequently null (the target row isn't mounted yet) and a tall QuestionWidget grows _outside_ the prose body — so no watcher was installed, and the view stayed pinned at the bottom until some unrelated event repositioned it. That is the user-reported "open a session on the phone, it lands at the bottom, then snaps to the top after several seconds." The follow-up adds, on mobile, a bounded `setTimeout` poll of the scroll container's own `scrollHeight` (the exact quantity the decision compares) so ANY late growth — from any element, with or without the prose ref — reliably and promptly re-measures and snaps to the message top. It runs through the same pure `shouldLateReclassify` gate (material-growth floor + the user-gesture veto + the post-mount time window) and is one-shot: whichever trigger (poll or observer) sees the growth first fires the re-measure and tears the rest down. Desktop is unchanged (its cold-mount is masked by the warm cache). Full contract: [frontend-scroll-contract.md § Cold-mount lite-content timing, rule #2](../../.claude/memory/contracts/frontend-scroll-contract.md).

## For agents

### The `extractProse` helper

`extractProse(content)` lives at [src/main/services/extract-prose.ts](../../src/main/services/extract-prose.ts) — a thin, deterministic wrapper. The trailing-prose walk itself lives ONCE in the shared canonical core `deriveAgentProse` ([src/shared/agent-prose-core.ts](../../src/shared/agent-prose-core.ts)); `extractProse` delegates to it and widens the result with `commentCount` + `toolCount` (W10/F020 — the walk was previously duplicated byte-for-byte with the renderer's `deriveProseAndActivity`, which is now also a one-line delegate). The core depends on the shared marker walkers (`@shared/agent-content-markers`) and the QW v3 parser (`@shared/question-widget-parser-v3`) — not config-free. It splits `content` by newline, tracks whether each line is inside a triple-backtick fence, and classifies non-fence lines as either tool-marker (`▸ ` for calls, `← ` for results) or prose. `extractProse` returns `{ prose: string, hasToolActivity: boolean, commentCount: number, toolCount: number, hasNonThinkingActivity: boolean }` (the last three feed the TurnActivityBubble "X comments, Y actions" label and the MergedTurnBubble-vs-MessageBubble discriminator).

The reason it's a dedicated helper rather than reusing the renderer's `parseContentSegments` is that `parseContentSegments` depends on the renderer's `PreprocessConfig` (link normalization, QW parser version, etc.) — config the main process doesn't have and shouldn't need. The dedicated fence-aware extractor is simpler, faster, and parity-tested against the renderer's output via [tests/unit/services/extract-prose-parity.test.ts](../../tests/unit/services/extract-prose-parity.test.ts) on 12 real-shaped fixtures.

### The kill switch

If a perf regression or a correctness bug shows up in lite mode in the wild, the user can flip one environment variable and restart:

```
AMC_DISABLE_LITE_COLD_MOUNT=1
```

With the kill switch set, `getSessionHistoryLite()` aliases back to `getSessionHistory()` — same lite shape, but with the full `content` populated on every row. The renderer is unchanged; the Actions pill renders inline from props (no IPC) because the content is already there. This is the "panic button" rollback path so the user never has to wait for a code change to land.

The flag is read once at boot, lives in [src/main/services/lite-mode-flag.ts](../../src/main/services/lite-mode-flag.ts), and any string other than the literal `"1"` is treated as "lite enabled" (so an absent variable, an empty string, `"0"`, or `"true"` all keep lite on).

### Where it lives in code

| What                                                                                           | Where                                                                                                                                                                                                                                                                                                                                                       |
| ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Schema columns + backfill-state table                                                          | [src/main/db/incremental-migrations.ts](../../src/main/db/incremental-migrations.ts) migration v218                                                                                                                                                                                                                                                         |
| `extractProse(content)` helper                                                                 | [src/main/services/extract-prose.ts](../../src/main/services/extract-prose.ts)                                                                                                                                                                                                                                                                              |
| Kill switch reader                                                                             | [src/main/services/lite-mode-flag.ts](../../src/main/services/lite-mode-flag.ts)                                                                                                                                                                                                                                                                            |
| Idle backfill service (**DORMANT — unwired 2026-05-24**)                                       | [src/main/services/summary-prose-backfill.ts](../../src/main/services/summary-prose-backfill.ts) + scheduler at [summary-prose-backfill-scheduler.ts](../../src/main/services/summary-prose-backfill-scheduler.ts); unwire locked by [tests/unit/lint/summary-prose-backfill-unwired.test.ts](../../tests/unit/lint/summary-prose-backfill-unwired.test.ts) |
| Partial-row repair migration                                                                   | [src/main/db/incremental-migrations.ts](../../src/main/db/incremental-migrations.ts) migration v231 + [tests/unit/db/migration-v231-partial-row-resentinel.test.ts](../../tests/unit/db/migration-v231-partial-row-resentinel.test.ts)                                                                                                                      |
| `getSessionHistoryLite()` + `mapMessageLite()` (agent + subagent-result strip)                 | [src/main/db/queries-messages/history.ts](../../src/main/db/queries-messages/history.ts)                                                                                                                                                                                                                                                                    |
| `SESSION_GET_TURN_CONTENT` IPC (source-agnostic — serves agent turns AND subagent-result rows) | handler in [src/main/ipc/session-handlers.ts](../../src/main/ipc/session-handlers.ts), channel in [src/shared/ipc-channels/session.ts](../../src/shared/ipc-channels/session.ts), schema in [src/shared/ipc-schemas.ts](../../src/shared/ipc-schemas.ts)                                                                                                    |
| Subagent-result card lazy-fetch + `subagentContentDeferred` marker consume                     | [src/renderer/src/components/ui/MessageBubble/MessageBubble.tsx](../../src/renderer/src/components/ui/MessageBubble/MessageBubble.tsx) (`isSubagentResult` block)                                                                                                                                                                                           |
| `LiteConversationMessage` shape                                                                | [src/shared/ipc-types.ts](../../src/shared/ipc-types.ts)                                                                                                                                                                                                                                                                                                    |
| Renderer placeholder for lite turns                                                            | `liteContent` prop on `AgentMarkdown`, threaded from `MessageBubble.tsx:extractLiteContent`                                                                                                                                                                                                                                                                 |
| Five-warmer lint pin                                                                           | [tests/unit/lint/lite-warmer-coverage.test.ts](../../tests/unit/lint/lite-warmer-coverage.test.ts)                                                                                                                                                                                                                                                          |
| Per-session LAM seed read (`getSessionSeedContent`)                                            | [src/main/db/queries-sessions/lifecycle.ts](../../src/main/db/queries-sessions/lifecycle.ts)                                                                                                                                                                                                                                                                |
| Live attention-transition seed enrichment (`enrichSessionStatusPushForRemote`, both transports) | [src/main/services/web/web-access-session-seed-enrich.ts](../../src/main/services/web/web-access-session-seed-enrich.ts) — wired in [web-access-push-forwarder.ts](../../src/main/services/web/web-access-push-forwarder.ts) + [web-access-host.ts](../../src/main/services/web/web-access-host.ts)                                                            |
| `lastAgentMessageContent` push field + renderer patch (`updateSessionLastAgentMessageContent`)  | [src/shared/push-event-schemas/session-lifecycle.ts](../../src/shared/push-event-schemas/session-lifecycle.ts) · [useSessionSync.ts](../../src/renderer/src/hooks/useSessionSync.ts) · [session-store.ts](../../src/renderer/src/stores/session-store.ts)                                                                                                     |

## Related

- [scroll-position-memory.md](scroll-position-memory.md) — the other layer that affects how a session paints on return.
- The postmortem in the repo: `.claude/memory/postmortems/lazy-content-load-architecture-postmortem.md` — the WHY this architecture, the alternatives that were considered and rejected, and the 10-lens red-team that shaped v2.
- The narration-leak follow-up postmortem in the repo: `.claude/memory/postmortems/lite-cold-mount-narration-leak-postmortem.md` — the regression mechanism and the renderer-only rescue (removed 2026-05-24).
