fix(terminal): remove scrollback demotion (C1 slice C)

The scrollback demotion feature for eviction-exempt hidden worktrees is no longer needed. Retention budget limits are now sufficient without this additional bound. Remove the terminal-hidden-scrollback-demotion module, the selectScrollbackDemotedTerminalWorktrees function, and related per-pane demotion logic.
This commit is contained in:
Jinjing
2026-07-28 13:21:54 -07:00
parent f576a79497
commit 667f4748ff
8 changed files with 12 additions and 421 deletions
+4 -33
View File
@@ -94,13 +94,8 @@ import {
import {
TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS,
selectRetentionForceParkedTerminalWorktrees,
selectScrollbackDemotedTerminalWorktrees,
type TerminalWorktreeRetentionCandidate
} from './terminal-pane/terminal-hidden-worktree-retention'
import {
resetTerminalScrollbackDemotion,
setScrollbackDemotedTerminalWorktrees
} from './terminal-pane/terminal-hidden-scrollback-demotion'
import { captureTerminalShutdownBuffersBestEffort } from './terminal-pane/shutdown-buffer-captures'
import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides'
import { useColdParkedTerminalPresentation } from './terminal-pane/use-cold-parked-terminal-presentation'
@@ -302,9 +297,6 @@ function Terminal(): React.JSX.Element | null {
const terminalRetentionBudgetEnabled = useAppStore(
(s) => s.settings?.terminalHiddenWorktreeRetentionBudget !== false
)
const terminalScrollbackDemotionEnabled = useAppStore(
(s) => s.settings?.terminalHiddenScrollbackDemotion !== false
)
const terminalTitleSnapshotAuthorityEnabled = useAppStore((s) =>
isMainTerminalSideEffectAuthorityForPty({
settings: s.settings,
@@ -1072,18 +1064,6 @@ function Terminal(): React.JSX.Element | null {
setForceParkedTerminalWorktreeIds((current) =>
haveSameWorktreeIds(current, forceParkedWorktreeIds) ? current : forceParkedWorktreeIds
)
// C1 slice C: eviction-exempt worktrees stay mounted, so past the TTL their
// hidden panes demote to the minimum scrollback tier instead of unmounting.
setScrollbackDemotedTerminalWorktrees(
selectScrollbackDemotedTerminalWorktrees({
worktrees: retentionBudgetCandidates,
parkingEnabled: terminalParkingEnabled,
retentionBudgetEnabled: terminalRetentionBudgetEnabled,
demotionEnabled: terminalScrollbackDemotionEnabled,
nowMs,
...overrides
})
)
const retentionTtlEligibleIds = new Set(
retentionBudgetCandidates
.filter((candidate) => !candidate.ordinaryParkingCovers && !candidate.hasPendingSpawnWork)
@@ -1105,10 +1085,8 @@ function Terminal(): React.JSX.Element | null {
parkCooldownUntilMs: candidate.parkCooldownUntilMs,
nowMs,
...overrides,
// Why: only retention candidates wake at the eviction TTL; everyone else keeps the ordinary
// deadlines. Demotion (slice C) needs the wakeup too — it no longer requires slice B's switch.
...((terminalRetentionBudgetEnabled || terminalScrollbackDemotionEnabled) &&
retentionTtlEligibleIds.has(candidate.worktreeId)
// Why: only retention candidates wake at the eviction TTL; everyone else keeps the ordinary deadlines.
...(terminalRetentionBudgetEnabled && retentionTtlEligibleIds.has(candidate.worktreeId)
? {
retentionTtlMs: overrides.retentionTtlMs ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
}
@@ -1133,7 +1111,6 @@ function Terminal(): React.JSX.Element | null {
terminalParkingEnabled,
terminalParkingRevision,
terminalRetentionBudgetEnabled,
terminalScrollbackDemotionEnabled,
terminalSshParkingEnabled,
workspaceSurfaces
])
@@ -1373,14 +1350,8 @@ function Terminal(): React.JSX.Element | null {
workspaceSessionReady,
workspaceSurfaces
])
// Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher and drop the demotion verdicts nothing would recompute.
useEffect(
() => () => {
disposeAllParkedTerminalWatchers()
resetTerminalScrollbackDemotion()
},
[]
)
// Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher.
useEffect(() => () => disposeAllParkedTerminalWatchers(), [])
// Auto-create first tab when worktree activates
useEffect(() => {
if (!workspaceSessionReady) {
@@ -1,71 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
TERMINAL_DEMOTED_SCROLLBACK_ROWS,
demotedTerminalScrollbackRows,
isTerminalWorktreeScrollbackDemoted,
resetTerminalScrollbackDemotion,
resolveTerminalMountScrollbackRows,
setScrollbackDemotedTerminalWorktrees,
subscribeTerminalScrollbackDemotion
} from './terminal-hidden-scrollback-demotion'
afterEach(() => {
setScrollbackDemotedTerminalWorktrees(new Set())
})
describe('setScrollbackDemotedTerminalWorktrees', () => {
it('replaces the demoted set', () => {
setScrollbackDemotedTerminalWorktrees(new Set(['wt-1', 'wt-2']))
expect(isTerminalWorktreeScrollbackDemoted('wt-1')).toBe(true)
setScrollbackDemotedTerminalWorktrees(new Set(['wt-2']))
expect(isTerminalWorktreeScrollbackDemoted('wt-1')).toBe(false)
expect(isTerminalWorktreeScrollbackDemoted('wt-2')).toBe(true)
})
it('does not notify when the set content is unchanged (flip-loop damping)', () => {
setScrollbackDemotedTerminalWorktrees(new Set(['wt-1']))
const listener = vi.fn()
const unsubscribe = subscribeTerminalScrollbackDemotion(listener)
setScrollbackDemotedTerminalWorktrees(new Set(['wt-1']))
expect(listener).not.toHaveBeenCalled()
setScrollbackDemotedTerminalWorktrees(new Set(['wt-1', 'wt-2']))
expect(listener).toHaveBeenCalledTimes(1)
unsubscribe()
})
})
describe('resetTerminalScrollbackDemotion', () => {
// Why: module state outlives the host, so a stale verdict would demote a pane
// that remounts before any effect recomputes it.
it('clears every verdict and notifies subscribers', () => {
setScrollbackDemotedTerminalWorktrees(new Set(['wt-1', 'wt-2']))
const listener = vi.fn()
const unsubscribe = subscribeTerminalScrollbackDemotion(listener)
resetTerminalScrollbackDemotion()
expect(isTerminalWorktreeScrollbackDemoted('wt-1')).toBe(false)
expect(isTerminalWorktreeScrollbackDemoted('wt-2')).toBe(false)
expect(listener).toHaveBeenCalledTimes(1)
unsubscribe()
})
})
describe('demotedTerminalScrollbackRows', () => {
it('never exceeds the configured rows', () => {
expect(demotedTerminalScrollbackRows(50_000)).toBe(TERMINAL_DEMOTED_SCROLLBACK_ROWS)
expect(demotedTerminalScrollbackRows(500)).toBe(500)
})
})
describe('resolveTerminalMountScrollbackRows', () => {
// Why: pane births (splits) under an already-demoted worktree bypass the
// post-mount demotion effect entirely — the mount options must demote.
it('demotes new xterms while the worktree verdict is set, restores after clear', () => {
setScrollbackDemotedTerminalWorktrees(new Set(['wt-demoted']))
expect(resolveTerminalMountScrollbackRows('wt-demoted', 50_000)).toBe(
TERMINAL_DEMOTED_SCROLLBACK_ROWS
)
expect(resolveTerminalMountScrollbackRows('wt-other', 50_000)).toBe(50_000)
resetTerminalScrollbackDemotion()
expect(resolveTerminalMountScrollbackRows('wt-demoted', 50_000)).toBe(50_000)
})
})
@@ -1,72 +0,0 @@
import { useSyncExternalStore } from 'react'
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN } from '../../../../shared/terminal-scrollback-policy'
/** Demoted hidden panes keep the minimum retention tier (~1.3MB V8 heap vs
* ~19MB at 50k rows, measured); trimmed history is gone by design — reveal
* restores the configured cap for future output only. */
export const TERMINAL_DEMOTED_SCROLLBACK_ROWS = DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN
// Why module state (same pattern as parked watchers): the verdict is computed
// in Terminal.tsx but applied by each mounted pane's lifecycle hook — prop
// threading would cross three component layers for a per-worktree boolean.
const demotedWorktreeIds = new Set<string>()
const listeners = new Set<() => void>()
export function setScrollbackDemotedTerminalWorktrees(next: ReadonlySet<string>): void {
if (
next.size === demotedWorktreeIds.size &&
[...next].every((id) => demotedWorktreeIds.has(id))
) {
return
}
demotedWorktreeIds.clear()
for (const id of next) {
demotedWorktreeIds.add(id)
}
for (const listener of listeners) {
listener()
}
}
/**
* Clear every verdict — the demotion host is gone, so nothing recomputes them.
* Without this a pane remounting later reads a stale demotion (React runs the
* pane's scrollback effect BEFORE the host effect that would clear it) and
* xterm trims its restore replay to 1 000 rows, unrecoverably.
*/
export function resetTerminalScrollbackDemotion(): void {
setScrollbackDemotedTerminalWorktrees(new Set())
}
export function isTerminalWorktreeScrollbackDemoted(worktreeId: string): boolean {
return demotedWorktreeIds.has(worktreeId)
}
export function subscribeTerminalScrollbackDemotion(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
/** Live per-worktree demotion verdict for a mounted pane host. */
export function useTerminalWorktreeScrollbackDemoted(worktreeId: string): boolean {
return useSyncExternalStore(subscribeTerminalScrollbackDemotion, () =>
demotedWorktreeIds.has(worktreeId)
)
}
export function demotedTerminalScrollbackRows(configuredRows: number): number {
return Math.min(TERMINAL_DEMOTED_SCROLLBACK_ROWS, configuredRows)
}
/** Scrollback for a NEW xterm in this worktree. Pane births under an
* already-demoted worktree (splits, layout replays) must take the demoted
* tier at create — the post-mount demotion effect only re-runs when the
* verdict or setting flips, so it never covers them. */
export function resolveTerminalMountScrollbackRows(
worktreeId: string,
configuredRows: number
): number {
return demotedWorktreeIds.has(worktreeId)
? demotedTerminalScrollbackRows(configuredRows)
: configuredRows
}
@@ -4,7 +4,6 @@ import {
TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS,
isEvictionExemptTerminalPty,
selectRetentionForceParkedTerminalWorktrees,
selectScrollbackDemotedTerminalWorktrees,
type TerminalWorktreeRetentionCandidate
} from './terminal-hidden-worktree-retention'
@@ -175,157 +174,3 @@ describe('selectRetentionForceParkedTerminalWorktrees', () => {
}
})
})
describe('selectScrollbackDemotedTerminalWorktrees', () => {
const nowMs = 5_000_000
const aged = nowMs - TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
const base = {
parkingEnabled: true,
retentionBudgetEnabled: true,
demotionEnabled: true,
nowMs
}
function exemptCandidate(
worktreeId: string,
hiddenSinceMs: number | null,
partial: Partial<TerminalWorktreeRetentionCandidate> = {}
): TerminalWorktreeRetentionCandidate {
return {
worktreeId,
hiddenSinceMs,
isVisible: false,
shouldMeasureHiddenWorktree: false,
hasActivityTerminalPortal: false,
ordinaryParkingCovers: false,
hasEvictionExemptTab: true,
hasPendingSpawnWork: false,
...partial
}
}
it('demotes only eviction-exempt worktrees hidden past the TTL', () => {
const selected = selectScrollbackDemotedTerminalWorktrees({
...base,
worktrees: [
exemptCandidate('wt-aged', aged),
exemptCandidate('wt-recent', nowMs - 60_000),
exemptCandidate('wt-not-exempt', aged, { hasEvictionExemptTab: false }),
exemptCandidate('wt-visible', aged, { isVisible: true }),
exemptCandidate('wt-portal', aged, { hasActivityTerminalPortal: true }),
exemptCandidate('wt-pending', aged, { hasPendingSpawnWork: true }),
exemptCandidate('wt-unhidden', null)
]
})
expect(selected).toEqual(new Set(['wt-aged']))
})
it('returns empty when the master or demotion switch is off', () => {
const worktrees = [exemptCandidate('wt-aged', aged)]
expect(
selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees, parkingEnabled: false })
).toEqual(new Set())
expect(
selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees, demotionEnabled: false })
).toEqual(new Set())
})
it('demotes independently of the slice-B retention-budget switch', () => {
const worktrees = [exemptCandidate('wt-aged', aged)]
expect(
selectScrollbackDemotedTerminalWorktrees({
...base,
worktrees,
retentionBudgetEnabled: false
})
).toEqual(new Set(['wt-aged']))
})
it('demotes an exempt worktree as soon as it force-parks under the count budget, before the TTL', () => {
// Why: after a force-park the exempt tabs' panes are the only ones left
// mounted in that worktree, so they take the demoted scrollback tier.
const underTtl = nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS
const worktrees = [
exemptCandidate('wt-a', underTtl - 3),
exemptCandidate('wt-b', underTtl - 2),
exemptCandidate('wt-c', underTtl - 1),
exemptCandidate('wt-d', underTtl)
]
expect(
selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees, retentionLimit: 2 })
).toEqual(new Set(['wt-a', 'wt-b']))
})
it('demotes un-parkable non-exempt worktrees the force-park lever spared, past the TTL', () => {
// wt-solo is the only candidate → last-active exempt from force-park; its
// panes stay fully mounted forever unless demotion bounds them.
const solo = [exemptCandidate('wt-solo', aged, { hasEvictionExemptTab: false })]
expect(selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees: solo })).toEqual(
new Set(['wt-solo'])
)
// With slice B off nothing force-parks; every un-parkable worktree past
// the TTL demotes (C bounds independently).
const pair = [
exemptCandidate('wt-a', aged - 1, { hasEvictionExemptTab: false }),
exemptCandidate('wt-b', aged, { hasEvictionExemptTab: false })
]
expect(
selectScrollbackDemotedTerminalWorktrees({
...base,
worktrees: pair,
retentionBudgetEnabled: false
})
).toEqual(new Set(['wt-a', 'wt-b']))
// With slice B on, wt-a force-parks (unmounted — no demotion needed) and
// only the spared last-active wt-b demotes.
expect(selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees: pair })).toEqual(
new Set(['wt-b'])
)
// Ordinary-parking-covered worktrees are bounded by the warm cap already.
const covered = [
exemptCandidate('wt-covered', aged, {
hasEvictionExemptTab: false,
ordinaryParkingCovers: true
})
]
expect(selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees: covered })).toEqual(
new Set()
)
})
it('is idempotent and only grows as time advances (flip-loop dwell)', () => {
const worktrees = [
exemptCandidate('wt-exempt', aged),
exemptCandidate('wt-force-parked', aged, { hasEvictionExemptTab: false }),
exemptCandidate('wt-last-active', nowMs - 60_000, { hasEvictionExemptTab: false })
]
const first = selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees })
expect(selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees })).toEqual(first)
// Why: with unchanged inputs, a later evaluation may only ADD members —
// a verdict that oscillates with time is the React-#185 ingredient.
for (const laterMs of [nowMs + 1_000, nowMs + TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS]) {
const later = selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees, nowMs: laterMs })
for (const id of first) {
expect(later.has(id)).toBe(true)
}
}
})
it('honors the retentionTtlMs override and stays time-monotone', () => {
const worktrees = [exemptCandidate('wt-1', nowMs - 500)]
expect(
selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees, retentionTtlMs: 400 })
).toEqual(new Set(['wt-1']))
expect(
selectScrollbackDemotedTerminalWorktrees({ ...base, worktrees, retentionTtlMs: 600 })
).toEqual(new Set())
expect(
selectScrollbackDemotedTerminalWorktrees({
...base,
worktrees,
retentionTtlMs: 600,
nowMs: nowMs + 200
})
).toEqual(new Set(['wt-1']))
})
})
@@ -20,10 +20,10 @@ export const TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS = 45 * 60_000
// Why: an eviction-exempt pty is a live local one a remount could not reattach
// (daemon-fail-open separator-less ids, ptys minted under another worktree) — a
// fresh spawn would orphan the live shell. Its TAB keeps its mounted pane when
// the worktree force-parks (per-tab exclusion, mirroring Activity portals) and
// relies on scrollback demotion instead. Per-PTY, not per-tab: the coverage
// veto that makes a worktree a retention candidate walks every split pane, so
// the exemption must too (see isEvictionExemptTerminalTab).
// the worktree force-parks (per-tab exclusion, mirroring Activity portals).
// Per-PTY, not per-tab: the coverage veto that makes a worktree a retention
// candidate walks every split pane, so the exemption must too (see
// isEvictionExemptTerminalTab).
export function isEvictionExemptTerminalPty(
ptyId: string | null | undefined,
worktreeId: string
@@ -98,66 +98,3 @@ export function selectRetentionForceParkedTerminalWorktrees(
hotRetainLimit: args.retentionLimit ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_LIMIT
})
}
/**
* Scrollback demotion targets the hidden panes that stay mounted past the
* retention deadlines: (a) eviction-exempt tabs' worktrees (their panes
* survive force-park via the per-tab exclusion) demote past the TTL — or
* immediately when their worktree force-parks under the count budget, since
* the exempt panes are then the only ones left mounted; (b) un-parkable
* worktrees the force-park lever spared (the last-active exemption, or slice
* B switched off) demote past the TTL instead of holding full scrollback
* forever. Time-monotone for fixed inputs: the force-parked and past-TTL sets
* only ever grow with nowMs and the last-active pick is time-independent, so
* membership only grows until a reveal resets hiddenSince — no oscillation
* surface.
*
* Demotion is WORKTREE-scoped by design: one eviction-exempt leaf demotes
* every still-mounted pane in the worktree, including parkable siblings a
* mixed split tab keeps mounted (exemption is tab-granular). Coarser than
* per-pane, deliberately — do not "fix" to per-pane demotion without
* revisiting the retention-budget contract in the C1 PR body.
*/
export function selectScrollbackDemotedTerminalWorktrees(
args: {
worktrees: readonly TerminalWorktreeRetentionCandidate[]
parkingEnabled: boolean
retentionBudgetEnabled: boolean
demotionEnabled: boolean
nowMs: number
} & TerminalColdParkPolicyOverrides
): Set<string> {
// Why parkingEnabled still gates: it is the master kill switch — parking
// globally off means nothing parks OR demotes. Slice B's budget switch does
// not gate slice C; each slice reverts independently (approved contract).
if (!args.parkingEnabled || !args.demotionEnabled) {
return new Set()
}
const ttlMs = args.retentionTtlMs ?? TERMINAL_HIDDEN_WORKTREE_RETENTION_TTL_MS
const forceParked = selectRetentionForceParkedTerminalWorktrees(args)
const demoted = new Set<string>()
for (const worktree of args.worktrees) {
if (
worktree.hiddenSinceMs === null ||
worktree.isVisible ||
worktree.shouldMeasureHiddenWorktree ||
worktree.hasActivityTerminalPortal ||
worktree.hasPendingSpawnWork
) {
continue
}
const pastTtl = args.nowMs - worktree.hiddenSinceMs >= ttlMs
if (worktree.hasEvictionExemptTab) {
if (pastTtl || forceParked.has(worktree.worktreeId)) {
demoted.add(worktree.worktreeId)
}
continue
}
// Force-park candidates the lever spared stay fully mounted, so demotion
// is their only bound; force-parked ones already unmounted.
if (!worktree.ordinaryParkingCovers && pastTtl && !forceParked.has(worktree.worktreeId)) {
demoted.add(worktree.worktreeId)
}
}
return demoted
}
@@ -15,11 +15,6 @@ import {
resolveTerminalCursorInactiveStyle
} from '@/lib/pane-manager/pane-terminal-options'
import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy'
import {
demotedTerminalScrollbackRows,
resolveTerminalMountScrollbackRows,
useTerminalWorktreeScrollbackDemoted
} from './terminal-hidden-scrollback-demotion'
import {
configureTerminalOutputBacklogCap,
writeTerminalOutput
@@ -574,8 +569,6 @@ export function useTerminalPaneLifecycle({
const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows(
settings?.terminalScrollbackRows
)
// C1 slice C: hidden eviction-exempt worktrees drop to the minimum scrollback tier past the retention TTL.
const scrollbackDemoted = useTerminalWorktreeScrollbackDemoted(worktreeId)
// Why here: backlog cap scales with the scrollback setting; set it where the setting is read to stay in lockstep.
configureTerminalOutputBacklogCap(settings?.terminalScrollbackRows)
const systemPrefersDarkRef = useRef(systemPrefersDark)
@@ -1334,12 +1327,8 @@ export function useTerminalPaneLifecycle({
fontFamily: buildFontFamily(currentSettings?.terminalFontFamily ?? ''),
fontWeight: terminalFontWeights.fontWeight,
fontWeightBold: terminalFontWeights.fontWeightBold,
// Why resolveTerminalMountScrollbackRows: pane births while the
// worktree is already demoted (C1 slice C) must not resurrect the
// full tier — the post-mount demotion effect doesn't re-run for them.
scrollback: resolveTerminalMountScrollbackRows(
worktreeId,
normalizeDesktopTerminalScrollbackRows(currentSettings?.terminalScrollbackRows)
scrollback: normalizeDesktopTerminalScrollbackRows(
currentSettings?.terminalScrollbackRows
),
cursorStyle,
cursorInactiveStyle: resolveTerminalCursorInactiveStyle(cursorStyle),
@@ -1768,13 +1757,8 @@ export function useTerminalPaneLifecycle({
return
}
// Why: live row-retention changes are xterm option updates only — must not recreate/replay/refit/resize/signal the PTY.
applyTerminalScrollbackRowsToMountedPanes(
manager,
scrollbackDemoted
? demotedTerminalScrollbackRows(terminalScrollbackRows)
: terminalScrollbackRows
)
}, [managerRef, scrollbackDemoted, terminalScrollbackRows])
applyTerminalScrollbackRowsToMountedPanes(manager, terminalScrollbackRows)
}, [managerRef, terminalScrollbackRows])
useEffect(() => {
const manager = managerRef.current
-1
View File
@@ -304,7 +304,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// settings objects (which omit them) keep the default-on behavior.
terminalSshViewParking: true,
terminalHiddenWorktreeRetentionBudget: true,
terminalHiddenScrollbackDemotion: true,
terminalMainSideEffectAuthority: true,
terminalHiddenDeliveryGate: true,
terminalModelQueryAuthority: true,
-2
View File
@@ -2852,8 +2852,6 @@ export type GlobalSettings = {
terminalSshViewParking?: boolean
/** Kill switch for the hidden-worktree retention budget (C1): force-parks the least-recently-hidden un-parkable worktrees beyond a count budget or TTL. */
terminalHiddenWorktreeRetentionBudget?: boolean
/** Kill switch for scrollback demotion (C1): eviction-exempt hidden worktrees drop to the minimum scrollback tier past the retention TTL. */
terminalHiddenScrollbackDemotion?: boolean
/** Kill switch for main-process PTY side-effect authority; on (default) = title/bell/agent facts via pty:sideEffect channel, not renderer byte parsing. */
terminalMainSideEffectAuthority?: boolean
/** Kill switch for main's hidden-delivery gate (Phase 4): drops PTY bytes to hidden views after model ingestion; requires terminalMainSideEffectAuthority. */