Stop hidden terminal panes from exhausting renderer memory (hidden-view parking, byte mode, default off)

Extracts the self-contained "hidden-view parking" slice from PR #7214
(orca-performance) as a standalone fix for the Windows renderer OOM/hang
under multi-agent load (#7175 / STA-476): long-hidden terminal panes
unmount after a 30s hysteresis while a pane-less byte watcher keeps
bell/title/agent-completion side effects alive; reveal rides the existing
tab-move reattach path.

Byte mode only: no main-process side-effect emitter ships here, so
constants.ts pins terminalMainSideEffectAuthority / terminalHiddenDeliveryGate /
terminalModelQueryAuthority to false (the single intentional divergence from
#7214, reconciled by taking #7214's values when its emitter lands). The
parking kill switch terminalHiddenViewParking also ships default-OFF
(opt-in) until the memory win is confirmed in a soak.

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-04 14:26:39 -07:00
co-authored by Jinwoo-H Orca
parent ac8d365296
commit ff5833dd4f
45 changed files with 6458 additions and 625 deletions
@@ -0,0 +1,118 @@
# Terminal Hidden View Parking
Status: Shipped standalone (byte mode only, extracted from PR #7214), kill
switch `terminalHiddenViewParking` **default off** — opt-in until the memory
win is confirmed in a soak, then a follow-up flips it on. The companion doc
`terminal-model-view-contract.md` (invariants this design extends and the full
phase list) ships with PR #7214 together with the fact-mode main emitter; in
this build `terminalMainSideEffectAuthority` must stay `false` (see
`src/shared/constants.ts`).
## Problem
Hidden terminal panes keep a full renderer xterm instance alive (buffer,
scrollback, DOM, addons). At many-worktree scale this is the dominant renderer
memory cost, and it forces every hidden byte through renderer-side write/skip
decisions. The main-process model (daemon + runtime headless emulators) already
ingests every byte and can serve restorable snapshots, so the renderer view for
a long-hidden pane is redundant state.
A previous attempt shipped and was reverted the same day. The post-mortem
finding: parking unmounted the pane component, which also tore down the
renderer's PTY byte parsers — and those parsers are the only source of bell
notifications, title-transition agent-complete notifications, and tab titles.
A parked worktree whose agent finished would never notify. This design keeps
those side effects alive while parked.
## Design
### Park policy (renderer)
A pure policy module decides which hidden terminal tabs may park:
- Cold-park hysteresis: a tab must be hidden for 30s before parking.
- Hot-retain working set: recently visible worktrees/tabs are retained
(5 minutes, bounded count) so quick tab switches never pay a re-hydrate.
- Eligibility excludes: visible panes, hidden-measuring startup probes,
activity-portal panes, tabs with pending startup commands or pending
activation spawns, floating-panel tabs, and any tab whose PTY is not
snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded).
- Kill switch: `settings.terminalHiddenViewParking === false` disables parking
entirely.
### Park mechanics
Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer
renders null for parked tabs). This is the same teardown that tab-group moves
already exercise: transports detach but the PTY session, daemon model, and tab
state all survive. The xterm instance, its buffers, DOM, and WebGL/addon
resources are released.
### Parked watcher (the piece the reverted attempt lacked)
While a tab is parked, a pane-less watcher
(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its
consumption mode is decided once at watcher start:
- **Main side-effect authority on (default):** the watcher is purely
fact-driven — it registers exactly one `pty:sideEffect` fact consumer and
parses no bytes. Titles, agent working/idle/exited transitions, BEL
attention, and PR links arrive as main-tracker facts and drive the same
policy callbacks a mounted pane uses. With the hidden-delivery gate also on,
the watcher marks the PTY hidden so main stops renderer byte delivery
entirely; the DECSET 2031 color-scheme subscribe arrives as main's
`2031-subscribe` fact and the watcher replies out-of-band via
`transport.sendInput`.
- **Kill switch off:** the watcher subscribes to raw bytes through the
dispatcher sidecar mechanism (the same mechanism background agent launches
use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2
titles (all-titles ordering, live-path normalization), the title-transition
agent tracker (completion notification, prompt-cache timer), the OSC-aware
stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031
byte responder (`parked-terminal-mode2031-responder.ts`, whose
`subscribeToPtyData` registration doubles as the delivery-interest signal).
The two modes drive one shared policy-callback block, so flipping the kill
switch never changes notification semantics. Main's synthetic
agent-title/permission frames feed the main tracker directly and arrive as
facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off
mode.
Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside
hidden-dropped chunks are answered by main's model responder
(`terminal-query-authority.md`, ships with PR #7214); in kill-switch-off byte
mode only the 2031 reply is answered and Command Code output is not scraped,
matching the pre-gate status quo.
### Reveal
Revealing a parked tab remounts the pane subtree and rides the existing
reattach path: fresh xterm via `openTerminal` (unicode provider activation
before any write), daemon model snapshot > relay replay > cold restore
precedence, replay-guarded so snapshot-embedded queries never answer, then
`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is
disposed before the pane handlers re-register.
## Invariants
1. PTY reads never stop; parking only changes renderer-side view lifetime.
2. Bell, agent-completion, title, and PR-link side effects keep working while
parked (watcher parity tests).
3. Reveal shows model-correct output (visual gates: hidden TUI restore, long
table, rendering golden) and accepts input immediately.
4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers
or strand parked state.
5. Memory: parked tabs hold no xterm buffers; renderer memory scales with
visible panes.
## Relation to later phases (all shipped)
Side-effect authority in main (Phase 3) replaced the watcher's byte parsing
with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4)
stops hidden byte delivery in main, moving the parked 2031 reply from the
byte sidecar to the `2031-subscribe` fact; the model query responder
(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser
mode survives only behind the kill switches. Parking still excludes
remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher
would return as a byte parser only if remote-runtime tabs — whose bytes never
transit local main — ever became parkable.
+20 -1
View File
@@ -5,6 +5,7 @@ const {
applyElectronProxySettingsMock,
browserWindowGetAllWindowsMock,
handleMock,
onMock,
previewGhosttyImportMock,
previewWarpThemeImportMock,
prepareLocalWorktreeRootsForReposMock,
@@ -14,6 +15,7 @@ const {
applyElectronProxySettingsMock: vi.fn(),
browserWindowGetAllWindowsMock: vi.fn(),
handleMock: vi.fn(),
onMock: vi.fn(),
previewGhosttyImportMock: vi.fn(),
previewWarpThemeImportMock: vi.fn(),
prepareLocalWorktreeRootsForReposMock: vi.fn(),
@@ -22,7 +24,7 @@ const {
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock },
ipcMain: { handle: handleMock },
ipcMain: { handle: handleMock, on: onMock },
nativeTheme: { themeSource: 'system' }
}))
@@ -70,6 +72,7 @@ const store = {
describe('registerSettingsHandlers', () => {
beforeEach(() => {
handleMock.mockClear()
onMock.mockClear()
applyAppIconMock.mockClear()
applyElectronProxySettingsMock.mockClear()
applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' })
@@ -89,6 +92,22 @@ describe('registerSettingsHandlers', () => {
expect(channels).toContain('settings:previewGhosttyImport')
})
it('answers the synchronous settings read with the persisted settings', () => {
// Why: panes can bind PTYs before async hydration; the side-effect
// authority kill switch needs the persisted value synchronously.
store.getSettings.mockReturnValue({ terminalMainSideEffectAuthority: false })
registerSettingsHandlers(store as never)
const listener = onMock.mock.calls.find(
(call) => call[0] === 'settings:get-sync'
)?.[1] as (event: { returnValue: unknown }) => void
expect(listener).toBeTypeOf('function')
const event = { returnValue: undefined as unknown }
listener(event)
expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false })
})
it('registers settings:previewWarpThemeImport handler', () => {
registerSettingsHandlers(store as never)
const channels = handleMock.mock.calls.map((call) => call[0])
+9
View File
@@ -66,6 +66,15 @@ export function registerSettingsHandlers(
return store.getSettings()
})
// Why: terminal panes can bind PTYs before async settings hydration
// completes. The side-effect authority kill switch is consulted once at
// transport creation, so the renderer needs the persisted value
// synchronously or pre-hydration bindings would always pick main authority
// (terminal-side-effect-authority.md, migration switch).
ipcMain.on('settings:get-sync', (event) => {
event.returnValue = store.getSettings()
})
ipcMain.handle('settings:set', async (event, args: Partial<GlobalSettings>) => {
const sanitizedArgs = sanitizeRendererSettingsUpdate(args)
// Why: Floating Workspace grants are trusted only when written by the
+18
View File
@@ -262,6 +262,7 @@ import type {
MigrationUnsupportedPtyEntry
} from '../shared/agent-status-types'
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
import type {
RuntimeBrowserDriverState,
RuntimeMobileSessionTabMove,
@@ -1155,6 +1156,11 @@ export type PreloadApi = {
ackData: (id: string, charCount: number) => void
setActiveRendererPty: (id: string, active: boolean) => void
setRendererPtyVisible: (id: string, visible: boolean) => void
/** Hidden-delivery gate (PR #7214): absent in this build — no main-process
* gate exists yet. Declared optional so gate-aware renderer code
* (parked-terminal byte watcher) typechecks; it only fires when the
* terminalHiddenDeliveryGate setting is on, which requires the emitter. */
setHiddenRendererPty?: (id: string, hidden: boolean) => void
hasChildProcesses: (id: string) => Promise<boolean>
getForegroundProcess: (id: string) => Promise<string | null>
getCwd: (id: string) => Promise<string>
@@ -1199,6 +1205,14 @@ export type PreloadApi = {
}) => void
) => () => void
onReplay: (callback: (data: { id: string; data: string }) => void) => () => void
/** Batched derived side-effect facts channel (PR #7214): absent in this
* build — main has no side-effect emitter yet. Declared optional so the
* renderer facts-handler typechecks and degrades to "no channel";
* terminalMainSideEffectAuthority defaults false until the emitter lands. */
onSideEffect?: (callback: (batch: TerminalSideEffectBatch) => void) => () => void
/** Title-only replay snapshot companion to onSideEffect (PR #7214):
* absent in this build for the same reason. */
getSideEffectSnapshot?: (id: string) => Promise<TerminalSideEffectBatch | null>
onExit: (callback: (data: { id: string; code: number }) => void) => () => void
onSerializeBufferRequest: (
callback: (data: {
@@ -1894,6 +1908,10 @@ export type PreloadApi = {
telemetryAcknowledgeBanner: () => Promise<void>
settings: {
get: () => Promise<GlobalSettings>
/** Synchronous persisted-settings read for startup decisions that cannot
* wait for async hydration (terminal side-effect authority). Blocking
* IPC — call sparingly. */
getSync: () => GlobalSettings | null
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
listFonts: () => Promise<string[]>
previewGhosttyImport: () => Promise<GhosttyImportPreview>
+4 -1
View File
@@ -20,5 +20,8 @@ const exposeStore = preloadEnv?.MODE === 'e2e' || isEnvFlagEnabled(preloadEnv?.V
export const preloadE2EConfig = createE2EConfig({
headless: process.env.ORCA_E2E_HEADLESS === '1',
exposeStore,
userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null
userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null,
// Why: Number('') is 0 and Number(undefined) is NaN; both coerce to null so
// only a real positive override reaches the renderer parking policy.
terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null
})
+4
View File
@@ -1688,6 +1688,10 @@ const api = {
settings: {
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),
// Why: blocking read for the few startup decisions (terminal side-effect
// authority) that cannot wait for async hydration. Call sparingly.
getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'),
set: (args: Record<string, unknown>): Promise<unknown> =>
ipcRenderer.invoke('settings:set', args),
+227 -1
View File
@@ -66,6 +66,18 @@ import {
import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs'
import {
getTerminalWorktreeColdParkRecheckDelayMs,
selectColdParkedTerminalWorktrees,
type TerminalWorktreeColdParkCandidate
} from './terminal-pane/terminal-hidden-view-parking'
import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides'
import {
canWatcherCoverParkedTerminalTab,
pruneParkedTerminalWatchers,
shouldDeferParkedPtyExitTabClose,
syncParkedTerminalTabWatchers
} from './terminal-pane/terminal-parked-tab-watchers'
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
import { setWindowCloseRequestHandler } from './window-close-request-coordinator'
import CodexRestartChip from './CodexRestartChip'
@@ -125,6 +137,18 @@ const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
type TerminalStoreSnapshot = ReturnType<typeof useAppStore.getState>
function haveSameWorktreeIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
if (left.size !== right.size) {
return false
}
for (const id of left) {
if (!right.has(id)) {
return false
}
}
return true
}
function findUnifiedTabByVisibleId(
state: TerminalStoreSnapshot,
worktreeId: string,
@@ -207,6 +231,8 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext {
function Terminal(): React.JSX.Element | null {
const mountedWorktreeIdsRef = useRef(new Set<string>())
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
const terminalWorktreeHiddenSinceRef = useRef(new Map<string, number>())
const terminalWorktreeParkingTimersRef = useRef(new Map<string, number>())
const allWorktrees = useAllWorktrees()
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
const workspaceSurfaces = useMemo(
@@ -223,6 +249,8 @@ function Terminal(): React.JSX.Element | null {
const renderedActiveWorktreeId = activeWorktreeId
const activeView = useAppStore((s) => s.activeView)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
const activeTabId = useAppStore((s) => s.activeTabId)
const createTab = useAppStore((s) => s.createTab)
const closeTab = useAppStore((s) => s.closeTab)
@@ -710,7 +738,11 @@ function Terminal(): React.JSX.Element | null {
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
// spawning when restoring a session with many saved worktree tabs.
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
const [, setBackgroundMountRevision] = useState(0)
const [backgroundMountRevision, setBackgroundMountRevision] = useState(0)
const [terminalParkingRevision, setTerminalParkingRevision] = useState(0)
const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState<ReadonlySet<string>>(
() => new Set()
)
useEffect(() => {
const timers = measurableBackgroundWorktreeTimersRef.current
const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current
@@ -748,6 +780,122 @@ function Terminal(): React.JSX.Element | null {
closeDialogDebounceTimers.clear()
}
}, [])
useEffect(() => {
const timers = terminalWorktreeParkingTimersRef.current
return () => {
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
}
}, [])
// Why: worktree-level cold-park policy — hiddenSince bookkeeping, parked-set
// selection, and one recheck timer per still-pending deadline so React
// re-renders exactly when the hysteresis elapses instead of polling.
useEffect(() => {
const parkingTimers = terminalWorktreeParkingTimersRef.current
for (const timer of parkingTimers.values()) {
window.clearTimeout(timer)
}
parkingTimers.clear()
const nowMs = Date.now()
const overrides = getTerminalParkingPolicyOverrides()
const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId))
const currentWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id))
for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) {
if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) {
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
}
}
const retentionCandidates: TerminalWorktreeColdParkCandidate[] = []
for (const workspace of workspaceSurfaces) {
const worktreeId = workspace.id
if (!mountedWorktreeIdsRef.current.has(worktreeId)) {
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
continue
}
const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId)
const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId)
if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) {
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
} else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) {
terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs)
}
retentionCandidates.push({
worktreeId,
terminalTabs: tabsByWorktree[worktreeId] ?? [],
isVisible,
shouldMeasureHiddenWorktree,
hasActivityTerminalPortal,
hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null
})
}
const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({
worktrees: retentionCandidates,
pendingStartupByTabId,
parkingEnabled: terminalParkingEnabled,
nowMs,
...overrides
})
// Why: a worktree with any tab the byte watchers cannot cover (no
// capture, no layout snapshot, legacy leaf ids) must never park — it
// would go silent for bells/titles/completions, the failure that sank
// the first parking attempt.
for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) {
const tabs = tabsByWorktree[worktreeId] ?? []
if (!tabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab))) {
nextParkedTerminalWorktreeIds.delete(worktreeId)
}
}
setParkedTerminalWorktreeIds((current) =>
haveSameWorktreeIds(current, nextParkedTerminalWorktreeIds)
? current
: nextParkedTerminalWorktreeIds
)
for (const candidate of retentionCandidates) {
if (
candidate.isVisible ||
candidate.shouldMeasureHiddenWorktree ||
candidate.hasActivityTerminalPortal ||
nextParkedTerminalWorktreeIds.has(candidate.worktreeId)
) {
continue
}
const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: terminalParkingEnabled,
hiddenSinceMs: candidate.hiddenSinceMs,
nowMs,
...overrides
})
if (delayMs !== null && delayMs > 0) {
const worktreeId = candidate.worktreeId
const timer = window.setTimeout(() => {
parkingTimers.delete(worktreeId)
setTerminalParkingRevision((revision) => revision + 1)
}, delayMs)
parkingTimers.set(worktreeId, timer)
}
}
}, [
activeView,
activityTerminalPortals,
backgroundMountRevision,
pendingStartupByTabId,
renderedActiveWorktreeId,
tabsByWorktree,
terminalParkingEnabled,
terminalParkingRevision,
workspaceSurfaces
])
// Why: gated on workspaceSessionReady to prevent TerminalPane from mounting
// before reconnectPersistedTerminals() has finished eagerly spawning PTYs.
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
@@ -770,6 +918,59 @@ function Terminal(): React.JSX.Element | null {
groupsByWorktree,
activeGroupIdByWorktree
)
// Why: parked byte-watcher reconciliation for the legacy (non-split)
// terminal host, which renders TerminalPanes directly. In split mode each
// TerminalPaneOverlayLayer owns its worktree's watchers, so here we only
// dispose worktrees that render no overlay layer (no layout / unmounted)
// and prune watchers for deleted worktrees.
useEffect(() => {
pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id)))
for (const workspace of workspaceSurfaces) {
if (
anyMountedWorktreeHasLayout &&
mountedWorktreeIdsRef.current.has(workspace.id) &&
getEffectiveLayoutForWorktree(workspace.id)
) {
continue
}
const tabs = tabsByWorktree[workspace.id] ?? []
const parkedTabIds = new Set<string>()
if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) {
const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const parked =
!isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id)
if (parked) {
for (const tab of tabs) {
const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
worktreeId: workspace.id,
tabId: tab.id
})
if (!activityTerminalPortal) {
parkedTabIds.add(tab.id)
}
}
}
}
syncParkedTerminalTabWatchers({ worktreeId: workspace.id, tabs, parkedTabIds })
}
}, [
activeView,
activityTerminalPortals,
anyMountedWorktreeHasLayout,
backgroundMountRevision,
getEffectiveLayoutForWorktree,
parkedTerminalWorktreeIds,
renderedActiveWorktreeId,
tabsByWorktree,
workspaceSurfaces
])
// Why: symmetric with useTerminalTabColdParking's unmount cleanup — when
// the terminal host unmounts, no reconciliation effect will run again, so
// dispose every remaining parked watcher here (overlay-layer children have
// already disposed theirs by the time this parent cleanup runs).
useEffect(() => () => pruneParkedTerminalWatchers(new Set()), [])
// Auto-create first tab when worktree activates
useEffect(() => {
if (!workspaceSessionReady) {
@@ -1068,6 +1269,12 @@ function Terminal(): React.JSX.Element | null {
if (consumeSuppressedPtyExit(ptyId)) {
return
}
// Why: a parked multi-leaf tab has no PaneManager to promote split
// siblings, so closing the tab here would kill them; the reveal
// remount handles dead PTYs per leaf instead.
if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) {
return
}
handleCloseTab(tabId)
},
[consumeSuppressedPtyExit, handleCloseTab]
@@ -1800,6 +2007,10 @@ function Terminal(): React.JSX.Element | null {
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const shouldColdParkTerminalPanes =
!isVisible &&
!shouldMeasureHiddenWorktree &&
parkedTerminalWorktreeIds.has(workspace.id)
return (
<WorktreeSplitSurface
key={`tab-groups-${workspace.id}`}
@@ -1809,6 +2020,7 @@ function Terminal(): React.JSX.Element | null {
focusedGroupId={activeGroupIdByWorktree[workspace.id]}
isVisible={isVisible}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
shouldColdParkTerminalPanes={shouldColdParkTerminalPanes}
activityTerminalPortals={activityTerminalPortals}
/>
)
@@ -1860,6 +2072,10 @@ function Terminal(): React.JSX.Element | null {
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
const shouldMeasureHiddenWorktree =
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
const shouldColdParkTerminalPanes =
!isVisible &&
!shouldMeasureHiddenWorktree &&
parkedTerminalWorktreeIds.has(workspace.id)
return (
<div
key={workspace.id}
@@ -1881,6 +2097,12 @@ function Terminal(): React.JSX.Element | null {
const isActivityPortalTab = activityTerminalPortal !== null
const isActiveTerminalTab =
isVisible && tab.id === activeTabId && activeTabType === 'terminal'
// Why: parking is exactly the unmount path tab-group
// moves use — transports detach, the PTY survives, and
// the parked byte watcher owns side effects until reveal.
if (shouldColdParkTerminalPanes && !isActivityPortalTab) {
return null
}
const terminalPane = (
<TerminalPane
key={`${tab.id}-${tab.generation ?? 0}`}
@@ -2087,6 +2309,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
focusedGroupId,
isVisible,
shouldMeasureHiddenWorktree,
shouldColdParkTerminalPanes,
activityTerminalPortals
}: {
worktreeId: string
@@ -2095,6 +2318,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
focusedGroupId?: string
isVisible: boolean
shouldMeasureHiddenWorktree: boolean
shouldColdParkTerminalPanes: boolean
activityTerminalPortals: ActivityTerminalPortalTarget[]
}): React.JSX.Element {
const browserPageIds = useAppStore(
@@ -2134,6 +2358,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
worktreeId={worktreeId}
worktreePath={worktreePath}
isWorktreeActive={isVisible}
coldParkTerminalPanes={shouldColdParkTerminalPanes}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
activityTerminalPortals={activityTerminalPortals}
/>
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
@@ -12,6 +12,8 @@ import {
import TerminalPane from './TerminalPane'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut'
import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers'
import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking'
type TerminalOverlayAssignment = {
unifiedTabId: string
@@ -237,6 +239,12 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
if (consumeSuppressedPtyExit(ptyId)) {
return
}
// Why: a parked multi-leaf tab has no PaneManager to promote split
// siblings, so closing the tab here would kill them; the reveal
// remount handles dead PTYs per leaf instead.
if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) {
return
}
closeTab(terminalTabId)
leaveWorktreeIfEmpty()
}}
@@ -278,11 +286,15 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
worktreeId,
worktreePath,
isWorktreeActive,
coldParkTerminalPanes = false,
shouldMeasureHiddenWorktree = false,
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS
}: {
worktreeId: string
worktreePath: string
isWorktreeActive: boolean
coldParkTerminalPanes?: boolean
shouldMeasureHiddenWorktree?: boolean
activityTerminalPortals?: ActivityTerminalPortalTarget[]
}): React.JSX.Element | null {
const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore(
@@ -346,6 +358,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
return entries
}, [groupActiveTabById, unifiedTabs])
const parkedTerminalTabIds = useTerminalTabColdParking({
worktreeId,
terminalTabs,
assignments,
isWorktreeActive,
coldParkTerminalPanes,
shouldMeasureHiddenWorktree,
activityTerminalPortals
})
if (!worktreePath) {
return null
}
@@ -360,6 +382,12 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
worktreeId,
tabId: terminalTab.id
})
// Why: parking is exactly the unmount path tab-group moves use —
// transports detach, the PTY and tab model survive, and the parked
// byte watcher takes over side effects until reveal remounts here.
if (parkedTerminalTabIds.has(terminalTab.id)) {
return null
}
return (
<TerminalOverlaySlot
key={terminalTab.id}
@@ -0,0 +1,66 @@
/**
* Agent-task-complete notification policy predicates and timing constants.
*
* Why extracted from pty-connection.ts: the parked byte watcher and the
* pty:sideEffect facts handler apply the exact live-path semantics without a
* pane, and policy must not drift between the three consumers
* (docs/reference/terminal-side-effect-authority.md). This module is
* deliberately dependency-light — no pane/xterm imports — so pane-less
* consumers can use it.
*/
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { GlobalSettings } from '../../../../shared/types'
/** Delay before BEL/completion OS notifications so the richer
* agent-task-complete notification can win a same-burst BEL race. */
export const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250
/** Hard cap on waiting for hook detail before dispatching a completion. */
export const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500
export const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
type NotificationSettingsState = {
settings: Pick<GlobalSettings, 'notifications' | 'experimentalTerminalAttention'> | null
}
export function isAgentTaskCompleteOsNotificationEnabledFromState(
state: NotificationSettingsState
): boolean {
const notifications = state.settings?.notifications
return notifications?.enabled !== false && notifications?.agentTaskComplete !== false
}
export function isTerminalAttentionEnabledFromState(state: NotificationSettingsState): boolean {
return state.settings?.experimentalTerminalAttention === true
}
/** Completion tracking runs when either consumer (OS notification or the
* experimental terminal-attention marker) is enabled. */
export function isAgentTaskCompleteTrackingEnabledFromState(
state: NotificationSettingsState
): boolean {
return (
isAgentTaskCompleteOsNotificationEnabledFromState(state) ||
isTerminalAttentionEnabledFromState(state)
)
}
export function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean {
return Boolean(
entry &&
Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS &&
(entry.lastAssistantMessage || entry.toolName || entry.toolInput)
)
}
export function canDispatchAgentNotificationAfterGrace(
entry: AgentStatusEntry | undefined,
options: { allowDoneDetailAfterGrace?: boolean } = {}
): boolean {
// Why: hook-backed goal/mission loops can report `done` between milestones.
// User-input states may notify as soon as detail arrives, but `done` waits
// for the max quiet window so resumed work can cancel the pending banner.
return (
hasAgentNotificationDetail(entry) &&
(entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true)
)
}
@@ -0,0 +1,723 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts'
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
const PTY_ID = 'pty-parked-1'
const TAB_ID = 'tab-1'
const WORKTREE_ID = 'repo-1::/tmp/wt-1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = `${TAB_ID}:${LEAF_ID}`
const PANE_ID = 1
// Mirrors PARKED_NOTIFICATION_GRACE_MS / AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS.
const NOTIFICATION_GRACE_MS = 250
// Real agent-detection titles: braille spinner classifies as working,
// the "✳ " Claude prefix as idle, and both as Claude agents.
const WORKING_TITLE_OSC = '\x1b]0;⠋ Build feature\x07'
const IDLE_TITLE = '✳ Build feature'
const IDLE_TITLE_OSC = `\x1b]0;${IDLE_TITLE}\x07`
type MockStoreState = {
settings: {
theme?: 'system' | 'dark' | 'light'
promptCacheTimerEnabled?: boolean
experimentalTerminalAttention?: boolean
terminalMainSideEffectAuthority?: boolean
terminalHiddenDeliveryGate?: boolean
notifications?: { enabled?: boolean; agentTaskComplete?: boolean }
} | null
setRuntimePaneTitle: ReturnType<typeof vi.fn>
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
updateTabTitle: ReturnType<typeof vi.fn>
markWorktreeUnread: ReturnType<typeof vi.fn>
markTerminalTabUnread: ReturnType<typeof vi.fn>
markTerminalPaneUnread: ReturnType<typeof vi.fn>
setCacheTimerStartedAt: ReturnType<typeof vi.fn>
observeTerminalGitHubPullRequestLink: ReturnType<typeof vi.fn>
}
const dispatchTerminalNotification = vi.fn()
let mockStoreState: MockStoreState
vi.mock('./use-notification-dispatch', () => ({
dispatchTerminalNotification
}))
vi.mock('@/lib/terminal-theme', () => ({
getSystemPrefersDark: () => true
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => mockStoreState
}
}))
function createMockStoreState(): MockStoreState {
return {
// Why: terminalMainSideEffectAuthority false pins the legacy byte-parser
// mode this suite was written for; the authority-on fact-consumer mode is
// covered by the dedicated describe block below.
settings: {
theme: 'system',
promptCacheTimerEnabled: true,
experimentalTerminalAttention: false,
terminalMainSideEffectAuthority: false,
notifications: { enabled: true, agentTaskComplete: true }
},
setRuntimePaneTitle: vi.fn(),
clearRuntimePaneTitle: vi.fn(),
updateTabTitle: vi.fn(),
markWorktreeUnread: vi.fn(),
markTerminalTabUnread: vi.fn(),
markTerminalPaneUnread: vi.fn(),
setCacheTimerStartedAt: vi.fn(),
observeTerminalGitHubPullRequestLink: vi.fn()
}
}
describe('startParkedTerminalByteWatcher', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
let onData: ((payload: { id: string; data: string }) => void) | null = null
function emit(data: string): void {
onData?.({ id: PTY_ID, data })
}
// The output processor defers title/bell side effects onto a 0ms drain timer.
function flushSideEffects(): void {
vi.advanceTimersByTime(0)
}
async function startWatcher(
overrides: Partial<ParkedTerminalByteWatcherOptions> = {}
): Promise<{ dispose: () => void; sendInput: ReturnType<typeof vi.fn> }> {
const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher')
const sendInput = vi.fn()
const dispose = startParkedTerminalByteWatcher({
ptyId: PTY_ID,
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneId: PANE_ID,
sendInput,
...overrides
})
return { dispose, sendInput }
}
beforeEach(() => {
vi.resetModules()
vi.useFakeTimers()
dispatchTerminalNotification.mockClear()
onData = null
mockStoreState = createMockStoreState()
;(globalThis as { window: typeof window }).window = {
...originalWindow,
api: {
pty: {
onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => {
onData = callback
return () => {}
}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
ackData: vi.fn()
}
}
} as unknown as typeof window
})
afterEach(() => {
vi.useRealTimers()
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
} else {
delete (globalThis as { window?: typeof window }).window
}
})
it('forwards every OSC title in order to the pane and tab title store actions', async () => {
const { dispose } = await startWatcher()
emit(`${WORKING_TITLE_OSC}${IDLE_TITLE_OSC}`)
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([
[TAB_ID, PANE_ID, '⠋ Build feature'],
[TAB_ID, PANE_ID, IDLE_TITLE]
])
expect(mockStoreState.updateTabTitle.mock.calls).toEqual([
[TAB_ID, '⠋ Build feature'],
[TAB_ID, IDLE_TITLE]
])
dispose()
})
it('drops the bare cursor-agent native title before it reaches the store', async () => {
const { dispose } = await startWatcher()
emit('\x1b]0;Cursor Agent\x07')
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled()
expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled()
dispose()
})
it('does not drive the tab title when drivesTabTitle is false', async () => {
const { dispose } = await startWatcher({ drivesTabTitle: false })
emit(IDLE_TITLE_OSC)
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled()
dispose()
})
it('marks unread on BEL and schedules the delayed terminal-bell OS notification', async () => {
const { dispose } = await startWatcher()
emit('build finished\x07')
flushSideEffects()
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID)
expect(mockStoreState.markTerminalPaneUnread).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
source: 'terminal-bell',
paneKey: PANE_KEY
})
dispose()
})
it('marks the exact pane unread when experimental terminal attention is enabled', async () => {
mockStoreState.settings = {
...mockStoreState.settings,
experimentalTerminalAttention: true
}
const { dispose } = await startWatcher()
emit('\x07')
flushSideEffects()
expect(mockStoreState.markTerminalPaneUnread).toHaveBeenCalledWith(PANE_KEY)
dispose()
})
it('does not treat an OSC-terminator BEL as a bell, even split across chunks', async () => {
const { dispose } = await startWatcher()
emit('\x1b]0;par')
emit('tial title\x07')
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
dispose()
})
it('fires the prompt-cache timer and agent-task-complete on working→idle', async () => {
const { dispose } = await startWatcher()
emit(WORKING_TITLE_OSC)
flushSideEffects()
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
emit(IDLE_TITLE_OSC)
flushSideEffects()
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
PANE_KEY,
expect.any(Number)
)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
source: 'agent-task-complete',
terminalTitle: IDLE_TITLE,
paneKey: PANE_KEY
})
dispose()
})
it('suppresses the completion OS notification when only terminal attention is on', async () => {
mockStoreState.settings = {
...mockStoreState.settings,
experimentalTerminalAttention: true,
notifications: { enabled: true, agentTaskComplete: false }
}
const { dispose } = await startWatcher()
emit(WORKING_TITLE_OSC)
emit(IDLE_TITLE_OSC)
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
source: 'agent-task-complete',
terminalTitle: IDLE_TITLE,
paneKey: PANE_KEY,
suppressOsNotification: true
})
dispose()
})
it('skips completion dispatch when tracking is fully disabled, keeping the cache timer', async () => {
mockStoreState.settings = {
...mockStoreState.settings,
experimentalTerminalAttention: false,
notifications: { enabled: false }
}
const { dispose } = await startWatcher()
emit(WORKING_TITLE_OSC)
emit(IDLE_TITLE_OSC)
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
PANE_KEY,
expect.any(Number)
)
dispose()
})
it('lets a same-burst completion supersede the pending bell OS notification', async () => {
const { dispose } = await startWatcher()
emit(WORKING_TITLE_OSC)
flushSideEffects()
emit(`${IDLE_TITLE_OSC}\x07`)
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 8)
// The bell still marks unread immediately; only the OS notification yields.
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(
WORKTREE_ID,
expect.objectContaining({ source: 'agent-task-complete' })
)
dispose()
})
it('cancels the pending completion and clears the cache timer when working resumes', async () => {
const { dispose } = await startWatcher()
emit(WORKING_TITLE_OSC)
emit(IDLE_TITLE_OSC)
flushSideEffects()
emit(WORKING_TITLE_OSC)
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
dispose()
})
it('answers a DECSET 2031 subscribe split across chunks via sendInput', async () => {
const { dispose, sendInput } = await startWatcher()
emit('\x1b[?20')
expect(sendInput).not.toHaveBeenCalled()
emit('31h')
expect(sendInput).toHaveBeenCalledTimes(1)
// theme=system + prefers-dark → dark reply per terminal-color-scheme-protocol.
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
emit('\x1b[?2031l')
expect(sendInput).toHaveBeenCalledTimes(1)
dispose()
})
it('stops answering DECSET 2031 after dispose', async () => {
const { dispose, sendInput } = await startWatcher()
dispose()
emit('\x1b[?2031h')
expect(sendInput).not.toHaveBeenCalled()
})
it('observes GitHub PR links across chunk boundaries', async () => {
const { dispose } = await startWatcher()
emit('PR: https://github.com/orca-dev/orca/pull/42')
expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled()
emit('1\r\ndone')
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1)
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith(
WORKTREE_ID,
expect.objectContaining({
url: 'https://github.com/orca-dev/orca/pull/421',
number: 421,
slug: { owner: 'orca-dev', repo: 'orca' }
})
)
dispose()
})
it('fires completion when seeded with a working title and the agent goes idle while parked', async () => {
// Why: the pane was working at park time; the watcher's fresh tracker
// must be seeded or this working→idle transition can never fire.
const { dispose } = await startWatcher({ initialTitle: '⠋ Build feature' })
emit(IDLE_TITLE_OSC)
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
source: 'agent-task-complete',
terminalTitle: IDLE_TITLE,
paneKey: PANE_KEY
})
dispose()
})
it('does not fire completion for an idle title without a seed or observed transition', async () => {
const { dispose } = await startWatcher()
emit(IDLE_TITLE_OSC)
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
dispose()
})
it('clears the watcher-written runtime title slot on dispose', async () => {
const { dispose } = await startWatcher()
emit(IDLE_TITLE_OSC)
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
dispose()
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID)
})
it('leaves the runtime title slot alone on dispose when it never wrote one', async () => {
const { dispose } = await startWatcher()
emit('plain output with no titles\r\n')
flushSideEffects()
dispose()
expect(mockStoreState.clearRuntimePaneTitle).not.toHaveBeenCalled()
})
it('shutdown dispose cancels the armed completion timer and silences the final flush', async () => {
const { dispose } = await startWatcher()
emit(WORKING_TITLE_OSC)
emit(IDLE_TITLE_OSC)
flushSideEffects()
// Equivalent to shutdownWorktreeTerminals → disposeParkedTerminalWatchersForPtyIds.
dispose()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
// The teardown flush that main emits after pty.kill must be a no-op.
emit('final teardown flush\x07')
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
})
it('dispose unregisters the sidecar and cancels the pending bell notification', async () => {
const { dispose } = await startWatcher()
emit('\x07')
flushSideEffects()
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1)
dispose()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
emit('\x07')
flushSideEffects()
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1)
// Idempotent: a second dispose must not throw or clobber another watcher.
dispose()
})
it('disposes the previous watcher when a new one starts for the same PTY', async () => {
await startWatcher({ paneId: 1 })
const second = await startWatcher({ paneId: 2 })
emit(IDLE_TITLE_OSC)
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1)
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2, IDLE_TITLE)
second.dispose()
})
// ─── Main side-effect authority (terminal-side-effect-authority.md) ────
//
// With the kill switch on, the watcher must not register byte parsers —
// main is the single byte parser and the watcher's policy block consumes
// pty:sideEffect facts instead. The byte sidecar stays ONLY for the 2031
// reply (query authority never moves to main); PR links arrive as facts.
describe('with main side-effect authority on', () => {
function enableMainAuthority(): void {
mockStoreState.settings = {
...mockStoreState.settings,
terminalMainSideEffectAuthority: true
}
}
async function dispatchFacts(
facts: TerminalSideEffectFact[],
options: { seq?: number; replay?: boolean } = {}
): Promise<void> {
const handler = await import('./terminal-side-effect-facts-handler')
handler._dispatchTerminalSideEffectBatchForTest({
ptyId: PTY_ID,
seq: options.seq ?? 0,
...(options.replay ? { replay: true } : {}),
facts
})
}
// NOTE (standalone parking extraction): #7214's copy of this suite also
// has an emitViaMainTrackerFacts helper + a byte-vs-fact parity case that
// replay the same bytes through shared/terminal-output-side-effects (the
// main-process tracker). That module is fact-mode machinery outside this
// byte-mode extraction, so the parity case returns with PR #7214.
it('does not consume bytes: a byte BEL produces no unread or notification', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
emit('build finished\x07')
flushSideEffects()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
dispose()
})
it('applies bell facts with the byte-mode policy: unread now, OS notification delayed', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
await dispatchFacts([{ kind: 'bell' }])
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
source: 'terminal-bell',
paneKey: PANE_KEY
})
dispose()
})
it('fires the cache timer and completion from working→idle facts', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
await dispatchFacts([
{ kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' },
{ kind: 'agent-working' }
])
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
await dispatchFacts([
{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE },
{ kind: 'agent-idle', title: IDLE_TITLE }
])
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
PANE_KEY,
expect.any(Number)
)
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
source: 'agent-task-complete',
terminalTitle: IDLE_TITLE,
paneKey: PANE_KEY
})
dispose()
})
it('clears state without completion attention for stale-derived idle facts', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
await dispatchFacts([
{ kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' },
{ kind: 'agent-working' }
])
// Main's unthrottled 3s stale-title rewrite: titles/cache clear, but a
// merely-paused agent must not earn a task-complete notification.
await dispatchFacts([
{
kind: 'title',
normalizedTitle: 'Build feature',
rawTitle: 'Build feature',
staleWorkingTitleClear: true
},
{ kind: 'agent-idle', title: 'Build feature', staleWorkingTitleClear: true }
])
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenLastCalledWith(
TAB_ID,
PANE_ID,
'Build feature'
)
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
dispose()
})
it('replay batches restore the title only — attention facts never replay', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
await dispatchFacts(
[
{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE },
{ kind: 'bell' },
{ kind: 'agent-idle', title: IDLE_TITLE }
],
{ replay: true }
)
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
expect(mockStoreState.setCacheTimerStartedAt).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
dispose()
})
it('answers DECSET 2031 from the main 2031-subscribe fact, never the byte scan', async () => {
// Why: with the hidden-delivery gate on (default), parked PTY bytes are
// dropped in main — the fact is the only 2031 signal, and the byte
// sidecar must NOT exist (its registration would re-enable delivery).
enableMainAuthority()
const { dispose, sendInput } = await startWatcher()
emit('\x1b[?2031h')
expect(sendInput).not.toHaveBeenCalled()
await dispatchFacts([{ kind: '2031-subscribe' }])
expect(sendInput).toHaveBeenCalledTimes(1)
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
// Why: pr-link facts arrive on the channel; byte-scanning here too
// would observe every link twice.
emit('PR: https://github.com/orca-dev/orca/pull/42\r\n')
expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled()
dispose()
})
it('marks the PTY hidden for delivery on start and clears it on dispose', async () => {
enableMainAuthority()
const setHiddenRendererPty = vi.fn()
;(
window as unknown as { api: { pty: Record<string, unknown> } }
).api.pty.setHiddenRendererPty = setHiddenRendererPty
const { dispose } = await startWatcher()
expect(setHiddenRendererPty).toHaveBeenCalledWith(PTY_ID, true)
dispose()
// Why: the unhide must land before reveal re-registers pane handlers —
// the watcher registry disposes watchers before the remount effect runs.
expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false)
})
it('keeps the byte 2031 responder and no hidden bit when the gate kill switch is off', async () => {
enableMainAuthority()
mockStoreState.settings = {
...mockStoreState.settings,
terminalHiddenDeliveryGate: false
} as MockStoreState['settings']
const setHiddenRendererPty = vi.fn()
;(
window as unknown as { api: { pty: Record<string, unknown> } }
).api.pty.setHiddenRendererPty = setHiddenRendererPty
const { dispose, sendInput } = await startWatcher()
// Gate off — bytes keep flowing, so the split-chunk byte scan answers.
emit('\x1b[?20')
expect(sendInput).not.toHaveBeenCalled()
emit('31h')
expect(sendInput).toHaveBeenCalledTimes(1)
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
// Why: a 2031-subscribe fact must not double-fire the reply in byte
// mode — exactly one responder owns the answer at any time.
await dispatchFacts([{ kind: '2031-subscribe' }])
expect(sendInput).toHaveBeenCalledTimes(1)
expect(setHiddenRendererPty).not.toHaveBeenCalled()
dispose()
})
it('observes PR links from pr-link facts with worktree attribution', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
const link = {
url: 'https://github.com/orca-dev/orca/pull/421',
slug: { owner: 'orca-dev', repo: 'orca' },
number: 421
}
await dispatchFacts([{ kind: 'pr-link', link }])
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1)
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith(
WORKTREE_ID,
link
)
dispose()
})
it('dispose unregisters the fact consumer and clears a written title slot', async () => {
enableMainAuthority()
const { dispose } = await startWatcher()
await dispatchFacts([{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }])
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
dispose()
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID)
await dispatchFacts([{ kind: 'bell' }])
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,348 @@
/**
* Parked terminal side-effect watcher.
*
* Why: parking unmounts the TerminalPane subtree, which tears down the pane's
* side-effect consumers — the parked tab's only source of bell, title,
* agent-completion, and PR-link policy. (Losing them is the gap that sank the
* first parking attempt.) Under main side-effect authority the watcher is
* purely fact-driven (one pty:sideEffect consumer, no byte parsing); with the
* kill switch off it registers the legacy byte parsers on the dispatcher
* sidecar channel. DECSET 2031 ownership follows the hidden-delivery gate:
* gate ON answers from main's '2031-subscribe' fact (no parked bytes exist),
* gate OFF keeps the byte sidecar (parked-terminal-mode2031-responder.ts).
* Either way the reply is sent from the renderer — query authority never
* moves to main. See docs/reference/terminal-hidden-view-parking.md and
* docs/reference/terminal-side-effect-authority.md.
*/
import { isClaudeAgent } from '../../../../shared/agent-detection'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { useAppStore } from '@/store'
import {
mode2031SequenceFor,
resolveTerminalColorSchemeMode
} from '../../../../shared/terminal-color-scheme-protocol'
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import {
AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS,
isAgentTaskCompleteOsNotificationEnabledFromState,
isAgentTaskCompleteTrackingEnabledFromState
} from './agent-task-complete-policy'
import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder'
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
import { createPtyOutputProcessor } from './pty-transport'
import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate'
import {
isMainTerminalSideEffectAuthorityForPty,
registerTerminalSideEffectFactConsumer
} from './terminal-side-effect-facts-handler'
import { dispatchTerminalNotification } from './use-notification-dispatch'
// Why: mirrors AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS in pty-connection.ts.
// The parked path must keep the live path's BEL-vs-completion race window so
// notification behavior is identical whether a tab is parked or mounted.
const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS
type StoreState = ReturnType<typeof useAppStore.getState>
function isAgentTaskCompleteOsNotificationEnabled(state: StoreState): boolean {
return isAgentTaskCompleteOsNotificationEnabledFromState(state)
}
function isAgentTaskCompleteTrackingEnabled(state: StoreState): boolean {
return isAgentTaskCompleteTrackingEnabledFromState(state)
}
export type ParkedTerminalByteWatcherOptions = {
ptyId: string
tabId: string
worktreeId: string
/** Stable terminal-layout leaf UUID. Combined with tabId into the paneKey
* used for cache-timer, unread, and notification attribution. */
leafId: string
/** PaneManager pane id the unmounted pane used. Runtime pane titles are
* keyed by it, so the watcher must write the slot the live path wrote —
* a different id would leave a stale (possibly "working") title behind. */
paneId: number
/** Whether this PTY's pane was the tab's active split pane. Mirrors the
* live path, where only the focused split drives the tab title. */
drivesTabTitle?: boolean
/** The pane's last known runtime title at park time. Seeds the agent
* tracker so an agent that was working when the pane unmounted still
* fires its completion when it goes idle while parked. */
initialTitle?: string
/** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */
sendInput: (data: string) => void
}
const parkedWatcherDisposersByPtyId = new Map<string, () => void>()
export function startParkedTerminalByteWatcher(
options: ParkedTerminalByteWatcherOptions
): () => void {
const { ptyId, tabId, worktreeId, paneId, sendInput } = options
const drivesTabTitle = options.drivesTabTitle ?? true
const paneKey = makePaneKey(tabId, options.leafId)
// Why: one watcher per PTY. A stale watcher from a previous park cycle would
// double-fire bell/completion side effects for the same bytes.
parkedWatcherDisposersByPtyId.get(ptyId)?.()
let disposed = false
let pendingBellNotification = false
// Why: a watcher-written runtime title (especially into a negative fallback
// slot) has no live pane to overwrite it after reveal; a stale 'working'
// entry would pin worktree status forever. Track writes so dispose can
// clear exactly the slot this watcher touched.
let wroteRuntimeTitleSlot = false
let bellNotificationTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteTimer: ReturnType<typeof setTimeout> | null = null
const clearBellNotificationTimer = (): void => {
if (bellNotificationTimer !== null) {
clearTimeout(bellNotificationTimer)
bellNotificationTimer = null
}
}
const clearAgentTaskCompleteTimer = (): void => {
if (agentTaskCompleteTimer !== null) {
clearTimeout(agentTaskCompleteTimer)
agentTaskCompleteTimer = null
}
}
// Why: like the live path, a BEL OS notification only yields when the
// pending completion would itself produce an OS notification.
const hasPendingAgentTaskCompleteNotification = (): boolean =>
agentTaskCompleteTimer !== null &&
isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState())
const scheduleTerminalBellNotification = (): void => {
if (bellNotificationTimer !== null) {
return
}
bellNotificationTimer = setTimeout(() => {
bellNotificationTimer = null
if (disposed) {
pendingBellNotification = false
return
}
if (hasPendingAgentTaskCompleteNotification()) {
return
}
pendingBellNotification = false
dispatchTerminalNotification(worktreeId, { source: 'terminal-bell', paneKey })
}, PARKED_NOTIFICATION_GRACE_MS)
}
// Why: one policy block for both consumption modes — byte parsing (kill
// switch off) and pty:sideEffect facts (main authority on). The semantics
// must be identical or flipping the switch changes notification behavior.
const sideEffectCallbacks = {
onTitleChange: (title: string): void => {
const state = useAppStore.getState()
wroteRuntimeTitleSlot = true
state.setRuntimePaneTitle(tabId, paneId, title)
if (drivesTabTitle) {
state.updateTabTitle(tabId, title)
}
},
onBell: (): void => {
const state = useAppStore.getState()
state.markWorktreeUnread(worktreeId)
state.markTerminalTabUnread(tabId)
if (state.settings?.experimentalTerminalAttention === true) {
state.markTerminalPaneUnread(paneKey)
}
// Why: agent CLIs often emit BEL in the same completion burst as their
// working→idle title change. Delay only the OS notification so the
// richer agent-task-complete notification can win (live-path parity).
pendingBellNotification = true
if (!hasPendingAgentTaskCompleteNotification()) {
scheduleTerminalBellNotification()
}
},
onAgentBecameIdle: (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => {
// Why: stale-derived idles come from main's unthrottled 3s timer, not
// observed bytes — clear session state, never schedule the completion
// notification a merely-paused agent did not earn (live-path parity).
if (meta?.staleWorkingTitleClear) {
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
return
}
const state = useAppStore.getState()
// Why: mirrors pty-connection — null settings means "not hydrated yet";
// a spurious timestamp is harmless while a dropped one loses the timer.
if (
isClaudeAgent(title) &&
(state.settings === null || state.settings.promptCacheTimerEnabled)
) {
state.setCacheTimerStartedAt(paneKey, Date.now())
}
if (!isAgentTaskCompleteTrackingEnabled(state)) {
return
}
clearAgentTaskCompleteTimer()
agentTaskCompleteTimer = setTimeout(() => {
agentTaskCompleteTimer = null
if (disposed) {
return
}
// Why: the completion supersedes a concurrent BEL so each completion
// burst yields exactly one OS notification, same as the live path.
pendingBellNotification = false
clearBellNotificationTimer()
dispatchTerminalNotification(worktreeId, {
source: 'agent-task-complete',
terminalTitle: title,
paneKey,
...(isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState())
? {}
: { suppressOsNotification: true })
})
}, PARKED_NOTIFICATION_GRACE_MS)
},
onAgentBecameWorking: (): void => {
// Why: a new API call refreshes the prompt-cache TTL, so clear any
// running countdown; it restarts when the agent next becomes idle.
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
clearAgentTaskCompleteTimer()
if (pendingBellNotification) {
scheduleTerminalBellNotification()
}
},
onAgentExited: (): void => {
// Why: title reverting to a plain shell means the agent session ended;
// a stale countdown must not survive in the sidebar while parked.
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
}
}
// Why: parking eligibility excludes remote-runtime and SSH PTYs, so every
// watched PTY's bytes transit local main — when the authority switch is on,
// the watcher must NOT register byte parsers (the fact consumer below is
// the single policy consumer; double registration would double-fire bells).
const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({
settings: useAppStore.getState().settings,
runtimeEnvironmentId: null
})
// Why: under the Phase-4 gate a parked PTY needs no renderer bytes at all —
// facts carry side effects and the reveal remount restores from the model
// snapshot. Decided once at watcher start: it picks which 2031 responder
// (byte sidecar vs fact reply) exists, so it must never flip per chunk.
const hiddenDeliveryGateActive =
mainSideEffectAuthority &&
isRendererHiddenPtyDeliveryGateEnabled(useAppStore.getState().settings)
const sendMode2031Reply = (): void => {
const settings = useAppStore.getState().settings
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
}
// Why (byte-parser mode only): reuse the transport's output processor so
// the parked path keeps the exact live-path parsing semantics — all-titles
// ordering, normalization, the cursor-agent native-title drop, the
// OSC-aware stateful bell detector, and the working/idle agent tracker.
// initialAgentTitle: an agent already working at park time must still
// produce a working→idle transition; main's continuous tracker covers this
// in fact-consumer mode.
const processor = mainSideEffectAuthority
? null
: createPtyOutputProcessor({
...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}),
...sideEffectCallbacks
})
// Why (byte-parser mode only): with main authority, pr-link facts arrive on
// the channel below; byte-scanning too would observe every link twice.
const observeTerminalGitHubPRLink = mainSideEffectAuthority
? null
: createTerminalGitHubPRLinkDetector()
const unregisterFactConsumer = mainSideEffectAuthority
? registerTerminalSideEffectFactConsumer({
ptyId,
// Why: no title snapshot on park — the pane's runtime title slot is
// already current at park time, exactly like the byte-parser mode.
callbacks: {
...sideEffectCallbacks,
onPrLink: (link) =>
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link),
// Why (gate mode only): bytes never arrive while gated, so the 2031
// subscribe arrives as a main-tracker fact instead of a byte scan.
// The reply is still sent from here — query authority stays with
// the view/watcher (model/view contract invariant 6).
...(hiddenDeliveryGateActive ? { onMode2031Subscribe: sendMode2031Reply } : {})
}
})
: null
// Why: no xterm exists while parked, so nothing answers a DECSET 2031
// subscription. With the hidden-delivery gate OFF the byte responder is the
// parked path's only byte consumer under main authority. With the gate ON it
// must NOT register: its subscribeToPtyData sidecar doubles as a
// delivery-interest signal that would force-feed bytes to the gated PTY —
// the fact callback above replaces the byte scan.
const stopMode2031Responder = hiddenDeliveryGateActive
? null
: startParkedTerminalMode2031Responder({ ptyId, sendInput })
// Why: parked tabs are the canonical hidden view — mark the PTY gated so
// main stops renderer byte delivery; dispose clears the bit before the
// reveal remount re-registers pane handlers (existing dispose ordering).
if (hiddenDeliveryGateActive) {
;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, true)
}
// Why (byte-parser mode only): with main authority the watcher consumes
// pty:sideEffect facts exclusively and registers NO byte parsers here —
// title/bell/agent parsing and the PR-link scan would double-fire policy.
const unsubscribeByteParsers =
processor === null
? null
: subscribeToPtyData(ptyId, (data) => {
// Why: empty pane callbacks — the watcher wants only the parser
// side effects; there is no xterm to deliver bytes to.
processor.processData(data, {})
if (observeTerminalGitHubPRLink) {
for (const link of observeTerminalGitHubPRLink(data)) {
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
}
}
})
const dispose = (): void => {
if (disposed) {
return
}
disposed = true
// Why: unhide BEFORE the reveal remount registers pane handlers — main
// resumes delivery and (if bytes were dropped) emits the restore marker
// the remounted pane's restore machinery consumes.
if (hiddenDeliveryGateActive) {
;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, false)
}
stopMode2031Responder?.()
unsubscribeByteParsers?.()
unregisterFactConsumer?.()
// Why: cancels the deferred side-effect drain, stale-title timer, and
// tracker/bell-detector state so the watcher cannot fire after the
// revealed pane's live parsers take over.
processor?.clearAccumulatedState()
clearBellNotificationTimer()
clearAgentTaskCompleteTimer()
pendingBellNotification = false
// Why: the store merge never deletes title slots, so a watcher-written
// entry would strand after reveal (the revealing pane re-registers under
// its own pane id) and could pin worktree status 'working'. The revealed
// pane repopulates its slot via its own title flow.
if (wroteRuntimeTitleSlot) {
wroteRuntimeTitleSlot = false
useAppStore.getState().clearRuntimePaneTitle(tabId, paneId)
}
if (parkedWatcherDisposersByPtyId.get(ptyId) === dispose) {
parkedWatcherDisposersByPtyId.delete(ptyId)
}
}
parkedWatcherDisposersByPtyId.set(ptyId, dispose)
return dispose
}
@@ -0,0 +1,50 @@
/**
* DECSET 2031 color-scheme responder for parked terminals (byte-scan mode).
*
* Why a dedicated byte sidecar: no xterm exists while a tab is parked, so
* nothing answers a TUI's mode-2031 theme subscription. Query authority stays
* with the view/watcher (model/view contract invariant 6), so this reply can
* never move to main. Phase 4: this subscribeToPtyData registration doubles
* as a delivery-interest signal, so it is only used while the hidden-delivery
* gate is OFF — gated parked PTYs answer from the main tracker's
* '2031-subscribe' fact instead (parked-terminal-byte-watcher.ts).
*
* Survives Phase 6 (skip-grammar deletion): mounted switch-off hidden panes
* answer 2031 from xterm once the background queue drains, but a PARKED tab
* has no xterm in any switch-off mode, and the '2031-subscribe' fact is only
* consumed while the gate is ON — this sidecar stays the only answerer here.
*/
import {
mode2031SequenceFor,
resolveTerminalColorSchemeMode,
scanMode2031Sequences
} from '../../../../shared/terminal-color-scheme-protocol'
import { useAppStore } from '@/store'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
export type ParkedTerminalMode2031ResponderOptions = {
ptyId: string
/** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */
sendInput: (data: string) => void
}
export function startParkedTerminalMode2031Responder(
options: ParkedTerminalMode2031ResponderOptions
): () => void {
const { ptyId, sendInput } = options
// Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan
// carries a bounded tail between chunks so split sequences still match.
let scanTail = ''
return subscribeToPtyData(ptyId, (data) => {
const scan = scanMode2031Sequences(scanTail, data)
scanTail = scan.tail
if (!scan.subscribe) {
return
}
// Why: reply with the resolved theme so TUIs that subscribe while parked
// still learn it before the pane is ever revealed.
const settings = useAppStore.getState().settings
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
})
}
@@ -130,7 +130,7 @@ import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-comman
import { createCommandCodeOutputStatusDetector } from './command-code-output-status'
import type { PtyDataMeta } from './pty-dispatcher'
import { getEagerPtyBufferHandle } from './pty-dispatcher'
import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector'
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery'
import {
CONPTY_DA1_RESPONSE,
@@ -73,7 +73,12 @@ type PtyOutputProcessorOptions = Pick<
| 'onAgentBecameWorking'
| 'onAgentExited'
| 'onAgentStatus'
>
> & {
/** Seed for processors that start mid-session (parked-tab byte watchers):
* the pane's last known title, so a working agent that finishes while the
* processor owns the stream still yields a working→idle transition. */
initialAgentTitle?: string
}
type ProcessPtyOutputOptions = {
replayingBufferedData?: boolean
@@ -95,7 +100,8 @@ export function createPtyOutputProcessor({
onAgentBecameIdle,
onAgentBecameWorking,
onAgentExited,
onAgentStatus
onAgentStatus,
initialAgentTitle
}: PtyOutputProcessorOptions): {
processData: (
data: string,
@@ -110,7 +116,11 @@ export function createPtyOutputProcessor({
} {
const bellDetector = createBellDetector()
const processAgentStatusChunk = createAgentStatusOscProcessor()
let lastEmittedTitle: string | null = null
// Why: seed both the emitted-title memory (stale-title probe) and the agent
// tracker so a mid-session processor behaves as if it had observed the
// pane's last live title — full parity with the live path it replaces.
let lastEmittedTitle: string | null =
initialAgentTitle !== undefined ? normalizeTerminalTitle(initialAgentTitle) : null
let staleTitleTimer: ReturnType<typeof setTimeout> | null = null
let sideEffectDrainTimer: ReturnType<typeof setTimeout> | null = null
let pendingSideEffects: PendingPtySideEffect[] = []
@@ -123,7 +133,8 @@ export function createPtyOutputProcessor({
onAgentBecameIdle?.(title)
},
onAgentBecameWorking,
onAgentExited
onAgentExited,
initialAgentTitle
)
: null
@@ -0,0 +1,45 @@
/**
* Renderer-side predicate for main's Phase-4 hidden PTY delivery gate.
*
* The gate only operates when main holds side-effect authority for the PTY
* (see isMainTerminalSideEffectAuthorityForPty) AND the gate-specific kill
* switch is on. Callers decide once at pane/watcher creation — the decision
* picks which mode-2031 responder is registered (byte sidecar vs fact reply),
* so it must never flip per chunk.
*/
import type { GlobalSettings } from '../../../../shared/types'
// Why: cached once per session — the blocking read should only ever run on
// the pre-hydration startup path, never per pane bind.
let persistedGateFlagCache: boolean | null | undefined
function readPersistedHiddenDeliveryGateFlagSync(): boolean | null {
if (persistedGateFlagCache === undefined) {
try {
const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync
persistedGateFlagCache =
typeof getSync === 'function' ? (getSync()?.terminalHiddenDeliveryGate ?? null) : null
} catch {
persistedGateFlagCache = null
}
}
return persistedGateFlagCache
}
export function isRendererHiddenPtyDeliveryGateEnabled(
settings: Pick<GlobalSettings, 'terminalHiddenDeliveryGate'> | null
): boolean {
if (settings !== null) {
return settings.terminalHiddenDeliveryGate !== false
}
// Why: settings hydrate asynchronously; a pane/watcher bound before
// hydration must honor the persisted kill switch — the responder-mode
// decision made here is never revisited (same rationale as the
// side-effect-authority sync read).
return readPersistedHiddenDeliveryGateFlagSync() !== false
}
/** Test seam: reset the persisted-flag cache between tests. */
export function _resetHiddenPtyDeliveryGateFlagCacheForTest(): void {
persistedGateFlagCache = undefined
}
@@ -0,0 +1,529 @@
import { describe, expect, it } from 'vitest'
import {
TERMINAL_TAB_HOT_RETAIN_MS,
TERMINAL_WORKTREE_HOT_RETAIN_MS,
TERMINAL_WORKTREE_PARK_DELAY_MS,
canParkTerminalTabRenderer,
canParkTerminalWorktreeRenderers,
getTerminalTabColdParkRecheckDelayMs,
getTerminalWorktreeColdParkRecheckDelayMs,
isSnapshotBackedTerminalPty,
selectColdParkedTerminalTabs,
selectColdParkedTerminalWorktrees
} from './terminal-hidden-view-parking'
describe('isSnapshotBackedTerminalPty', () => {
it('allows local daemon sessions owned by the worktree', () => {
expect(isSnapshotBackedTerminalPty('repo::/worktree@@session-1', 'repo::/worktree')).toBe(true)
expect(isSnapshotBackedTerminalPty('wt-1@@session-1', 'wt-1')).toBe(true)
})
// Why: separator-less ids ('1', '2', 'pty-local-detached') come from the
// daemon-fail-open LocalPtyProvider and have no daemon session model —
// revealing a parked pane would silently respawn a fresh shell, so they
// must not count as snapshot-backed (changed from the ported prior art).
it('rejects separator-less local PTY ids that lack a daemon session model', () => {
expect(isSnapshotBackedTerminalPty('pty-local-detached', 'repo::/worktree')).toBe(false)
expect(isSnapshotBackedTerminalPty('1', 'wt-1')).toBe(false)
})
it('rejects tabs that do not have a PTY yet', () => {
expect(isSnapshotBackedTerminalPty(null, 'repo::/worktree')).toBe(false)
})
it('rejects daemon sessions owned by another worktree', () => {
expect(isSnapshotBackedTerminalPty('repo::/other@@session-1', 'repo::/worktree')).toBe(false)
expect(isSnapshotBackedTerminalPty('wt-2@@session-1', 'wt-1')).toBe(false)
})
it('rejects SSH and remote runtime PTY handles', () => {
expect(isSnapshotBackedTerminalPty('ssh:ssh-1@@pty-1', 'repo::/worktree')).toBe(false)
expect(isSnapshotBackedTerminalPty('remote:env-1@@terminal-1', 'repo::/worktree')).toBe(false)
})
})
describe('canParkTerminalWorktreeRenderers', () => {
const hiddenSinceMs = 1_000
const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
const base = {
worktreeId: 'repo::/worktree',
terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }],
pendingStartupByTabId: {},
parkingEnabled: true,
isVisible: false,
shouldMeasureHiddenWorktree: false,
hasActivityTerminalPortal: false,
hiddenSinceMs,
nowMs
}
it('parks hidden local terminal renderers after the idle delay', () => {
expect(canParkTerminalWorktreeRenderers(base)).toBe(true)
})
it('never parks when the settings kill switch disables parking', () => {
expect(canParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false)
expect(
canParkTerminalWorktreeRenderers({
...base,
parkingEnabled: false,
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_HOT_RETAIN_MS * 10
})
).toBe(false)
})
it('keeps renderers mounted while visible, measuring, portaled, or before the delay', () => {
expect(canParkTerminalWorktreeRenderers({ ...base, isVisible: true })).toBe(false)
expect(canParkTerminalWorktreeRenderers({ ...base, shouldMeasureHiddenWorktree: true })).toBe(
false
)
expect(canParkTerminalWorktreeRenderers({ ...base, hasActivityTerminalPortal: true })).toBe(
false
)
expect(
canParkTerminalWorktreeRenderers({
...base,
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS - 1
})
).toBe(false)
})
it('honors a per-call cold-park delay override', () => {
const shortDelayArgs = { ...base, coldParkDelayMs: 100 }
expect(canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 99 })).toBe(
false
)
expect(
canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 100 })
).toBe(true)
})
it('keeps the renderer mounted when any terminal lacks snapshot-backed restore', () => {
expect(
canParkTerminalWorktreeRenderers({
...base,
terminalTabs: [
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' },
{ id: 'tab-2', ptyId: 'ssh:ssh-1@@pty-1' }
]
})
).toBe(false)
})
it('keeps renderers mounted while a tab has startup or activation work pending', () => {
expect(
canParkTerminalWorktreeRenderers({
...base,
pendingStartupByTabId: { 'tab-1': { command: 'echo pending' } }
})
).toBe(false)
expect(
canParkTerminalWorktreeRenderers({
...base,
terminalTabs: [
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true }
]
})
).toBe(false)
expect(
canParkTerminalWorktreeRenderers({
...base,
terminalTabs: [
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 }
]
})
).toBe(false)
})
})
describe('canParkTerminalTabRenderer', () => {
const hiddenSinceMs = 1_000
const base = {
worktreeId: 'wt-1',
terminalTab: {
id: 'tab-1',
ptyId: 'wt-1@@session-1',
isVisible: false,
hasActivityTerminalPortal: false,
hiddenSinceMs
},
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
}
it('parks an idle hidden local tab and honors the kill switch', () => {
expect(canParkTerminalTabRenderer(base)).toBe(true)
expect(canParkTerminalTabRenderer({ ...base, parkingEnabled: false })).toBe(false)
})
it('honors a per-call cold-park delay override', () => {
expect(
canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 99 })
).toBe(false)
expect(
canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 100 })
).toBe(true)
})
})
describe('selectColdParkedTerminalWorktrees', () => {
const nowMs = 500_000
function localCandidate(worktreeId: string, hiddenSinceMs: number) {
return {
worktreeId,
terminalTabs: [{ id: `tab-${worktreeId}`, ptyId: `${worktreeId}@@session-1` }],
isVisible: false,
shouldMeasureHiddenWorktree: false,
hasActivityTerminalPortal: false,
hiddenSinceMs
}
}
it('keeps recent hidden local worktrees hot up to the retain limit', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1)
],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 2
})
expect(selected).toEqual(new Set())
})
it('cold-parks the oldest hidden local worktrees beyond the retain limit', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
localCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 2
})
expect(selected).toEqual(new Set(['wt-3']))
})
it('cold-parks aged local worktrees even when under the retain limit', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 4
})
expect(selected).toEqual(new Set(['wt-1']))
})
it('selects nothing when the settings kill switch disables parking', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS * 2)
],
pendingStartupByTabId: {},
parkingEnabled: false,
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set())
})
it('does not cold-park terminals without local snapshot recovery', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
localCandidate('wt-local', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
{
...localCandidate('wt-ssh', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
terminalTabs: [{ id: 'tab-ssh', ptyId: 'ssh:ssh-1@@pty-1' }]
},
{
...localCandidate('wt-remote', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
terminalTabs: [{ id: 'tab-remote', ptyId: 'remote:env-1@@terminal-1' }]
}
],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set(['wt-local']))
})
it('keeps visible, measuring, portaled, and pending terminals mounted', () => {
const selected = selectColdParkedTerminalWorktrees({
worktrees: [
{
...localCandidate('wt-visible', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
isVisible: true
},
{
...localCandidate('wt-measuring', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
shouldMeasureHiddenWorktree: true
},
{
...localCandidate('wt-portal', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
hasActivityTerminalPortal: true
},
{
...localCandidate('wt-activation', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
terminalTabs: [
{
id: 'tab-activation',
ptyId: 'wt-activation@@session-1',
pendingActivationSpawn: true
}
]
},
localCandidate('wt-startup', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)
],
pendingStartupByTabId: { 'tab-wt-startup': { command: 'echo pending' } },
parkingEnabled: true,
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set())
})
})
describe('selectColdParkedTerminalTabs', () => {
const nowMs = 500_000
function localTab(id: string, hiddenSinceMs: number) {
return {
id,
ptyId: `wt-1@@session-${id}`,
pendingActivationSpawn: false,
isVisible: false,
hasActivityTerminalPortal: false,
hiddenSinceMs
}
}
it('keeps visible and recent inactive terminal tabs mounted', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
{ ...localTab('tab-visible', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), isVisible: true },
localTab('tab-recent-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
localTab('tab-recent-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1)
],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 2
})
expect(selected).toEqual(new Set())
})
it('cold-parks the oldest inactive local tabs beyond the retain limit', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
localTab('tab-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
localTab('tab-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
localTab('tab-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 2
})
expect(selected).toEqual(new Set(['tab-3']))
})
it('cold-parks aged inactive local tabs even when under the retain limit', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS)],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 12
})
expect(selected).toEqual(new Set(['tab-1']))
})
it('selects nothing when the settings kill switch disables parking', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
localTab('tab-2', nowMs - TERMINAL_TAB_HOT_RETAIN_MS * 2)
],
pendingStartupByTabId: {},
parkingEnabled: false,
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set())
})
it('does not cold-park inactive terminal tabs without local snapshot recovery', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
localTab('tab-local', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
{
...localTab('tab-ssh', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
ptyId: 'ssh:ssh-1@@pty-1'
},
{
...localTab('tab-remote', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
ptyId: 'remote:env-1@@terminal-1'
}
],
pendingStartupByTabId: {},
parkingEnabled: true,
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set(['tab-local']))
})
it('keeps portaled, pending-startup, and pending-activation terminal tabs mounted', () => {
const selected = selectColdParkedTerminalTabs({
worktreeId: 'wt-1',
terminalTabs: [
{
...localTab('tab-portal', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
hasActivityTerminalPortal: true
},
localTab('tab-startup', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
{
...localTab('tab-activation', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
pendingActivationSpawn: true
}
],
pendingStartupByTabId: { 'tab-startup': { command: 'echo pending' } },
parkingEnabled: true,
nowMs,
hotRetainLimit: 0
})
expect(selected).toEqual(new Set())
})
})
describe('getTerminalWorktreeColdParkRecheckDelayMs', () => {
it('returns the next cold-park policy deadline', () => {
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: null,
nowMs: 1_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 1_050,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBe(50)
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 1_100,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBe(900)
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 2_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
})
it('schedules no recheck when the settings kill switch disables parking', () => {
expect(
getTerminalWorktreeColdParkRecheckDelayMs({
parkingEnabled: false,
hiddenSinceMs: 1_000,
nowMs: 1_050,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
})
})
describe('getTerminalTabColdParkRecheckDelayMs', () => {
it('returns the next terminal-tab cold-park policy deadline', () => {
expect(
getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: null,
nowMs: 1_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
expect(
getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 1_050,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBe(50)
expect(
getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 1_100,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBe(900)
expect(
getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: true,
hiddenSinceMs: 1_000,
nowMs: 2_000,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
})
it('schedules no recheck when the settings kill switch disables parking', () => {
expect(
getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: false,
hiddenSinceMs: 1_000,
nowMs: 1_050,
coldParkDelayMs: 100,
hotRetainMs: 1_000
})
).toBeNull()
})
})
@@ -0,0 +1,285 @@
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import type { TerminalTab } from '../../../../shared/types'
// Why: cold-park hysteresis keeps a hidden pane mounted for 30s so quick tab
// flips never pay a re-hydrate; hot-retain keeps a bounded recently-visible
// working set warm for 5 minutes beyond that.
export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000
export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000
export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4
export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000
export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000
export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12
// Why: tests override these per call (instead of process.env reads inside the
// module) to shrink the 30s hysteresis to test-friendly durations.
export type TerminalColdParkPolicyOverrides = {
coldParkDelayMs?: number
hotRetainMs?: number
hotRetainLimit?: number
}
export type ColdParkableTerminalTab = Pick<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>
export type TerminalWorktreeColdParkCandidate = {
worktreeId: string
terminalTabs: readonly ColdParkableTerminalTab[]
isVisible: boolean
shouldMeasureHiddenWorktree: boolean
hasActivityTerminalPortal: boolean
hiddenSinceMs: number | null
}
export type TerminalTabColdParkCandidate = ColdParkableTerminalTab & {
isVisible: boolean
hasActivityTerminalPortal: boolean
hiddenSinceMs: number | null
}
function getPendingActivationSpawnCount(value: boolean | number | undefined): number {
if (value === true) {
return 1
}
return typeof value === 'number' && value > 0 ? value : 0
}
// Why: parking relies on the daemon model snapshot to re-hydrate. Remote
// runtime and SSH PTYs have no local snapshot in this phase, and a session id
// minted for another worktree reattaches through a path parking cannot replay.
export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: string): boolean {
if (!ptyId) {
return false
}
if (isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) {
return false
}
// Why: separator-less ids come from the daemon-fail-open LocalPtyProvider;
// they have no daemon session model, so revealing a parked pane would
// silently respawn a fresh shell instead of restoring the snapshot.
const separatorIdx = ptyId.lastIndexOf(PTY_SESSION_ID_SEPARATOR)
return separatorIdx !== -1 && ptyId.slice(0, separatorIdx) === worktreeId
}
export function canParkTerminalWorktreeRenderers(args: {
worktreeId: string
terminalTabs: readonly ColdParkableTerminalTab[]
pendingStartupByTabId: Readonly<Record<string, unknown>>
// Why: callers pass settings.terminalHiddenViewParking !== false — the
// design-doc kill switch that disables parking entirely.
parkingEnabled: boolean
isVisible: boolean
shouldMeasureHiddenWorktree: boolean
hasActivityTerminalPortal: boolean
hiddenSinceMs: number | null
nowMs: number
coldParkDelayMs?: number
}): boolean {
if (
!args.parkingEnabled ||
args.isVisible ||
args.shouldMeasureHiddenWorktree ||
args.hasActivityTerminalPortal ||
args.hiddenSinceMs === null
) {
return false
}
if (
args.nowMs - args.hiddenSinceMs <
(args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS)
) {
return false
}
return args.terminalTabs.every((tab) => {
if (args.pendingStartupByTabId[tab.id] !== undefined) {
return false
}
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
return false
}
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
})
}
export function canParkTerminalTabRenderer(args: {
worktreeId: string
terminalTab: TerminalTabColdParkCandidate
pendingStartupByTabId: Readonly<Record<string, unknown>>
parkingEnabled: boolean
nowMs: number
coldParkDelayMs?: number
}): boolean {
const tab = args.terminalTab
if (
!args.parkingEnabled ||
tab.isVisible ||
tab.hasActivityTerminalPortal ||
tab.hiddenSinceMs === null
) {
return false
}
if (args.nowMs - tab.hiddenSinceMs < (args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)) {
return false
}
if (args.pendingStartupByTabId[tab.id] !== undefined) {
return false
}
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
return false
}
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
}
type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number }
// Why: hot-retain keeps the most recently hidden ids warm up to the limit;
// ids hidden past hotRetainMs or beyond the limit cold-park. Ties sort by id
// so the selection is deterministic.
function selectIdsBeyondHotRetain(
candidates: ColdParkRetainCandidate[],
args: { nowMs: number; hotRetainMs: number; hotRetainLimit: number }
): Set<string> {
const coldParkedIds = new Set<string>()
const retainedCandidates: ColdParkRetainCandidate[] = []
for (const candidate of candidates) {
if (args.nowMs - candidate.hiddenSinceMs >= args.hotRetainMs) {
coldParkedIds.add(candidate.id)
} else {
retainedCandidates.push(candidate)
}
}
retainedCandidates.sort((a, b) => {
const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs
return recencyDelta === 0 ? a.id.localeCompare(b.id) : recencyDelta
})
for (const candidate of retainedCandidates.slice(Math.max(0, args.hotRetainLimit))) {
coldParkedIds.add(candidate.id)
}
return coldParkedIds
}
export function selectColdParkedTerminalWorktrees(
args: {
worktrees: readonly TerminalWorktreeColdParkCandidate[]
pendingStartupByTabId: Readonly<Record<string, unknown>>
parkingEnabled: boolean
nowMs: number
} & TerminalColdParkPolicyOverrides
): Set<string> {
if (!args.parkingEnabled) {
return new Set()
}
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
const candidates: ColdParkRetainCandidate[] = []
for (const worktree of args.worktrees) {
if (
worktree.hiddenSinceMs === null ||
!canParkTerminalWorktreeRenderers({
...worktree,
pendingStartupByTabId: args.pendingStartupByTabId,
parkingEnabled: args.parkingEnabled,
nowMs: args.nowMs,
coldParkDelayMs
})
) {
continue
}
candidates.push({ id: worktree.worktreeId, hiddenSinceMs: worktree.hiddenSinceMs })
}
return selectIdsBeyondHotRetain(candidates, {
nowMs: args.nowMs,
hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS,
hotRetainLimit: args.hotRetainLimit ?? TERMINAL_WORKTREE_HOT_RETAIN_LIMIT
})
}
export function selectColdParkedTerminalTabs(
args: {
worktreeId: string
terminalTabs: readonly TerminalTabColdParkCandidate[]
pendingStartupByTabId: Readonly<Record<string, unknown>>
parkingEnabled: boolean
nowMs: number
} & TerminalColdParkPolicyOverrides
): Set<string> {
if (!args.parkingEnabled) {
return new Set()
}
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS
const candidates: ColdParkRetainCandidate[] = []
for (const tab of args.terminalTabs) {
if (
tab.hiddenSinceMs === null ||
!canParkTerminalTabRenderer({
worktreeId: args.worktreeId,
terminalTab: tab,
pendingStartupByTabId: args.pendingStartupByTabId,
parkingEnabled: args.parkingEnabled,
nowMs: args.nowMs,
coldParkDelayMs
})
) {
continue
}
candidates.push({ id: tab.id, hiddenSinceMs: tab.hiddenSinceMs })
}
return selectIdsBeyondHotRetain(candidates, {
nowMs: args.nowMs,
hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS,
hotRetainLimit: args.hotRetainLimit ?? TERMINAL_TAB_HOT_RETAIN_LIMIT
})
}
// Why: parking decisions change only at the cold-park and hot-retain
// deadlines, so callers schedule one recheck at the next deadline instead of
// polling.
function nextColdParkDeadlineDelayMs(args: {
parkingEnabled: boolean
hiddenSinceMs: number | null
nowMs: number
coldParkDelayMs: number
hotRetainMs: number
}): number | null {
if (!args.parkingEnabled || args.hiddenSinceMs === null) {
return null
}
const pendingDeadlines = [
args.hiddenSinceMs + args.coldParkDelayMs,
args.hiddenSinceMs + args.hotRetainMs
].filter((deadlineMs) => deadlineMs > args.nowMs)
return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs
}
export function getTerminalWorktreeColdParkRecheckDelayMs(args: {
parkingEnabled: boolean
hiddenSinceMs: number | null
nowMs: number
coldParkDelayMs?: number
hotRetainMs?: number
}): number | null {
return nextColdParkDeadlineDelayMs({
parkingEnabled: args.parkingEnabled,
hiddenSinceMs: args.hiddenSinceMs,
nowMs: args.nowMs,
coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS
})
}
export function getTerminalTabColdParkRecheckDelayMs(args: {
parkingEnabled: boolean
hiddenSinceMs: number | null
nowMs: number
coldParkDelayMs?: number
hotRetainMs?: number
}): number | null {
return nextColdParkDeadlineDelayMs({
parkingEnabled: args.parkingEnabled,
hiddenSinceMs: args.hiddenSinceMs,
nowMs: args.nowMs,
coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS
})
}
@@ -0,0 +1,483 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
const WORKTREE_ID = 'repo::/worktree'
const OTHER_WORKTREE_ID = 'repo::/other-worktree'
const TAB_ID = 'tab-1'
const PTY_ID = `${WORKTREE_ID}@@session-1`
const SECOND_PTY_ID = `${WORKTREE_ID}@@session-2`
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
type StartedWatcher = {
options: ParkedTerminalByteWatcherOptions
dispose: ReturnType<typeof vi.fn>
}
const startedWatchers: StartedWatcher[] = []
const startParkedTerminalByteWatcher = vi.fn((options: ParkedTerminalByteWatcherOptions) => {
const dispose = vi.fn()
startedWatchers.push({ options, dispose })
return dispose
})
vi.mock('./parked-terminal-byte-watcher', () => ({
startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) =>
startParkedTerminalByteWatcher(options)
}))
type ExitSubscription = {
ptyId: string
callback: (code: number) => void
unsubscribe: ReturnType<typeof vi.fn>
}
const exitSubscriptions: ExitSubscription[] = []
const subscribeToPtyExit = vi.fn((ptyId: string, callback: (code: number) => void) => {
const unsubscribe = vi.fn()
exitSubscriptions.push({ ptyId, callback, unsubscribe })
return unsubscribe
})
vi.mock('./pty-dispatcher', () => ({
subscribeToPtyExit: (ptyId: string, callback: (code: number) => void) =>
subscribeToPtyExit(ptyId, callback)
}))
type MockStoreState = {
terminalLayoutsByTabId: Record<
string,
{
root: unknown
activeLeafId: string | null
expandedLeafId: string | null
ptyIdsByLeafId?: Record<string, string>
}
>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
}
let mockStoreState: MockStoreState
vi.mock('@/store', () => ({
useAppStore: { getState: () => mockStoreState }
}))
import {
canWatcherCoverParkedTerminalTab,
captureParkedTerminalPaneCandidates,
disposeParkedTerminalWatchersForPtyIds,
disposeParkedTerminalWatchersForWorktree,
fallbackParkedPaneCandidates,
getParkedTerminalWatcherTabIds,
pruneParkedTerminalWatchers,
shouldDeferParkedPtyExitTabClose,
syncParkedTerminalTabWatchers
} from './terminal-parked-tab-watchers'
const ptyWrite = vi.fn()
const originalWindow = (globalThis as { window?: unknown }).window
function capturePanes(
panes: { ptyId: string | null; paneId: number; leafId: string; drivesTabTitle: boolean }[],
args?: { tabId?: string; worktreeId?: string }
): void {
captureParkedTerminalPaneCandidates(args?.tabId ?? TAB_ID, args?.worktreeId ?? WORKTREE_ID, panes)
}
function syncParked(args?: {
worktreeId?: string
tabs?: { id: string; ptyId: string | null }[]
parkedTabIds?: Iterable<string>
}): void {
syncParkedTerminalTabWatchers({
worktreeId: args?.worktreeId ?? WORKTREE_ID,
tabs: args?.tabs ?? [{ id: TAB_ID, ptyId: PTY_ID }],
parkedTabIds: new Set(args?.parkedTabIds ?? [TAB_ID])
})
}
describe('terminal-parked-tab-watchers', () => {
beforeEach(() => {
mockStoreState = {
terminalLayoutsByTabId: {},
runtimePaneTitlesByTabId: {},
clearRuntimePaneTitle: vi.fn()
}
;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } }
})
afterEach(() => {
// Module-level registries persist across tests; clear them through the
// public prune path so each test starts from an empty parked state.
pruneParkedTerminalWatchers(new Set())
startedWatchers.length = 0
exitSubscriptions.length = 0
vi.clearAllMocks()
;(globalThis as { window?: unknown }).window = originalWindow
})
it('starts one watcher per captured snapshot-backed PTY with the captured pane identity', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2)
expect(startedWatchers[0].options).toMatchObject({
ptyId: PTY_ID,
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneId: 1,
drivesTabTitle: true
})
expect(startedWatchers[1].options).toMatchObject({
ptyId: SECOND_PTY_ID,
paneId: 2,
drivesTabTitle: false
})
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
})
it('routes watcher sendInput to window.api.pty.write for the watched PTY', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
startedWatchers[0].options.sendInput('\x1b[?2031;1$y')
expect(ptyWrite).toHaveBeenCalledWith(PTY_ID, '\x1b[?2031;1$y')
})
it('skips legacy non-UUID leaf ids instead of throwing in makePaneKey', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
expect(startedWatchers[0].options).toMatchObject({ ptyId: SECOND_PTY_ID })
})
it('never starts watchers for remote-runtime or SSH PTYs', () => {
capturePanes([
{ ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] })
expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled()
// Why: the tab is still tracked as parked so debug introspection
// (window.__terminalParkingDebug) reflects every parked tab.
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
})
it('keeps existing watchers across repeated syncs of the same parked state', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
syncParked()
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
expect(startedWatchers[0].dispose).not.toHaveBeenCalled()
})
it('disposes the watcher and exit subscription when the tab unparks', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
syncParked({ parkedTabIds: [] })
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
expect(getParkedTerminalWatcherTabIds()).toEqual([])
})
it('disposes the watcher when the tab closes while parked', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
syncParked({ tabs: [], parkedTabIds: [TAB_ID] })
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(getParkedTerminalWatcherTabIds()).toEqual([])
})
it('disposes a PTY watcher when that PTY exits while parked', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
const exited = exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)
exited?.callback(0)
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(startedWatchers[1].dispose).not.toHaveBeenCalled()
// The tab itself is still parked, only the exited PTY's watcher is gone.
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
})
it('seeds each watcher with the pane slot last known runtime title', () => {
mockStoreState.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: '⠋ Build feature' } }
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
expect(startedWatchers[0].options.initialTitle).toBe('⠋ Build feature')
expect(startedWatchers[1].options.initialTitle).toBeUndefined()
})
it('drops the parked tab entry when the pty-exit sidecar disposes the last watcher', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0)
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(getParkedTerminalWatcherTabIds()).toEqual([])
})
it('synchronously disposes watchers for the given PTY ids without unparking the tab', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
disposeParkedTerminalWatchersForPtyIds([PTY_ID])
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
expect(startedWatchers[1].dispose).not.toHaveBeenCalled()
// Why: the entry survives so a sleeping parked tab cannot restart a
// watcher against its stale PTY ids before wake re-mints them.
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
syncParked()
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2)
})
it('restarts watchers from store layout when the tab PTY was re-minted', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
const remintedPtyId = `${WORKTREE_ID}@@session-after-wake`
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: remintedPtyId }
}
syncParked({ tabs: [{ id: TAB_ID, ptyId: remintedPtyId }] })
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2)
expect(startedWatchers[1].options).toMatchObject({ ptyId: remintedPtyId, leafId: LEAF_ID })
})
it('scopes sync disposal to the given worktree', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
const otherPtyId = `${OTHER_WORKTREE_ID}@@session-9`
capturePanes([{ ptyId: otherPtyId, paneId: 1, leafId: SECOND_LEAF_ID, drivesTabTitle: true }], {
tabId: 'tab-other',
worktreeId: OTHER_WORKTREE_ID
})
syncParked({
worktreeId: OTHER_WORKTREE_ID,
tabs: [{ id: 'tab-other', ptyId: otherPtyId }],
parkedTabIds: ['tab-other']
})
// Unparking everything in the other worktree must not touch this one.
syncParked({ worktreeId: OTHER_WORKTREE_ID, tabs: [], parkedTabIds: [] })
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
expect(startedWatchers[0].dispose).not.toHaveBeenCalled()
})
it('disposes all of a worktree watchers on worktree teardown and prunes deleted worktrees', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
disposeParkedTerminalWatchersForWorktree(WORKTREE_ID)
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
expect(getParkedTerminalWatcherTabIds()).toEqual([])
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
pruneParkedTerminalWatchers(new Set([OTHER_WORKTREE_ID]))
expect(getParkedTerminalWatcherTabIds()).toEqual([])
})
describe('shouldDeferParkedPtyExitTabClose', () => {
const closeTab = vi.fn()
// Mirrors both hosts' onPtyExit wiring: the guard runs before closeTab.
function hostOnPtyExit(tabId: string, ptyId: string): void {
if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) {
return
}
closeTab(tabId)
}
it('defers tab close on PTY exit in a parked multi-leaf tab and clears the dead slot', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
hostOnPtyExit(TAB_ID, PTY_ID)
expect(closeTab).not.toHaveBeenCalled()
// The dead leaf's runtime-title slot cannot pin worktree status.
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1)
})
it('keeps exit→closeTab parity for a parked single-leaf tab', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
hostOnPtyExit(TAB_ID, PTY_ID)
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
})
it('keeps exit→closeTab parity when the tab is not parked', () => {
hostOnPtyExit(TAB_ID, PTY_ID)
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
})
it('closes the tab when the last surviving leaf of a parked split exits', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
syncParked()
// First leaf dies: deferred, then its exit sidecar drops the watcher.
hostOnPtyExit(TAB_ID, PTY_ID)
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0)
expect(closeTab).not.toHaveBeenCalled()
hostOnPtyExit(TAB_ID, SECOND_PTY_ID)
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
})
})
describe('canWatcherCoverParkedTerminalTab', () => {
it('rejects a tab with no unmount capture and no layout snapshot', () => {
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
false
)
})
it('accepts a current capture whose panes are all snapshot-backed', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
true
)
})
it('rejects a capture containing a legacy non-UUID leaf id', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true },
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
false
)
})
it('rejects a capture containing a PTY without snapshot backing', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
])
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
false
)
})
it('accepts layout-derived candidates when the capture is stale', () => {
capturePanes([{ ptyId: 'old-pty', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
}
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
true
)
})
it('rejects layout-derived candidates missing a leaf PTY binding', () => {
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
root: {
type: 'split',
direction: 'row',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
},
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
}
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
false
)
})
})
})
describe('fallbackParkedPaneCandidates', () => {
it('returns nothing without a layout snapshot', () => {
expect(
fallbackParkedPaneCandidates(
{ id: TAB_ID, ptyId: PTY_ID },
{ terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} }
)
).toEqual([])
})
it('reuses the single runtime-title slot for a single-pane tab', () => {
expect(
fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, {
terminalLayoutsByTabId: {
[TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: null }
},
runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'working title' } }
} as never)
).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }])
})
it('maps split leaves to layout PTYs with collision-free negative pane ids', () => {
expect(
fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, {
terminalLayoutsByTabId: {
[TAB_ID]: {
root: {
type: 'split',
direction: 'row',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
},
activeLeafId: SECOND_LEAF_ID,
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID }
}
},
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'a', 2: 'b' } }
} as never)
).toEqual([
{ ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: false },
{ ptyId: SECOND_PTY_ID, paneId: -2, leafId: SECOND_LEAF_ID, drivesTabTitle: true }
])
})
})
@@ -0,0 +1,267 @@
/**
* Parked terminal tab watcher lifecycle.
*
* Why: parking unmounts a tab's TerminalPane, so its PTYs lose the renderer
* byte parsers. This module owns the pane-less replacement: it remembers the
* unmounted panes' identities (pane id / leaf id), starts one
* parked-terminal-byte-watcher per PTY when a tab parks, and disposes them on
* reveal, tab close, PTY exit, or worktree teardown. The bookkeeping maps
* live in terminal-parked-watcher-registry so the terminals store slice can
* dispose watchers without importing this store-coupled module.
* See docs/reference/terminal-hidden-view-parking.md.
*/
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import type { TerminalTab } from '../../../../shared/types'
import { useAppStore } from '@/store'
import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids'
import { subscribeToPtyExit } from './pty-dispatcher'
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking'
import {
capturedPanesByTabId,
disposeParkedTabWatchers,
parkedWatchersByTabId,
type ParkedTerminalPaneCapture
} from './terminal-parked-watcher-registry'
// Why: re-exported so park wiring keeps one import surface; the registry
// split exists only to break the store-slice import cycle.
export {
captureParkedTerminalPaneCandidates,
disposeParkedTerminalWatchersForPtyIds,
disposeParkedTerminalWatchersForWorktree,
getParkedTerminalWatcherTabIds,
pruneParkedTerminalWatchers
} from './terminal-parked-watcher-registry'
export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry'
export type ParkableTerminalTabModel = Pick<TerminalTab, 'id' | 'ptyId'>
type ParkedPaneFallbackState = {
terminalLayoutsByTabId: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
runtimePaneTitlesByTabId: ReturnType<typeof useAppStore.getState>['runtimePaneTitlesByTabId']
}
// Why: if no unmount capture exists (or it predates a PTY respawn), derive
// pane identities from the persisted layout snapshot. Numeric pane ids are
// unknown here: reuse the single existing runtime-title slot when unambiguous
// so a stale "working" title still gets overwritten, otherwise use negative
// slots that can never collide with real PaneManager ids.
export function fallbackParkedPaneCandidates(
tab: ParkableTerminalTabModel,
state: ParkedPaneFallbackState
): ParkedTerminalPaneCapture[] {
const layout = state.terminalLayoutsByTabId[tab.id]
const leafIds = collectLeafIdsInOrder(layout?.root)
if (leafIds.length === 0) {
return []
}
const ptyIdsByLeafId = layout?.ptyIdsByLeafId ?? {}
const titleSlots = Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {})
const reusableSlot =
leafIds.length === 1 && titleSlots.length === 1 ? Number(titleSlots[0]) : null
return leafIds.map((leafId, index) => ({
ptyId: ptyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null),
paneId: reusableSlot ?? -(index + 1),
leafId,
drivesTabTitle: layout?.activeLeafId ? leafId === layout.activeLeafId : index === 0
}))
}
// Why: unmount captures and layout fallbacks must resolve identically for the
// watcher start path and the park-eligibility coverage check, or a tab could
// pass the check and then start with different (uncoverable) candidates.
function resolveParkedTerminalPaneCandidates(
tab: ParkableTerminalTabModel,
state: ParkedPaneFallbackState
): ParkedTerminalPaneCapture[] {
const captured = capturedPanesByTabId.get(tab.id)
// Why: a capture that no longer mentions the tab's current PTY is stale
// (the PTY was re-minted since the unmount); fall back to the layout.
const capturedIsCurrent =
captured !== undefined &&
captured.panes.length > 0 &&
(tab.ptyId === null || captured.panes.some((pane) => pane.ptyId === tab.ptyId))
return capturedIsCurrent ? captured.panes : fallbackParkedPaneCandidates(tab, state)
}
/**
* Whether the parked byte watchers can fully cover this tab's PTYs (some
* candidate exists and every candidate has a snapshot-backed PTY bound to a
* valid leaf). Hosts must refuse to park a tab that fails this check —
* parking it would silently drop bell/title/completion side effects, the
* exact failure that sank the first parking attempt.
*/
// Extraction-only guard (standalone parking PR, not in PR #7214): this build
// ships no main-process side-effect emitter, so a persisted
// terminalMainSideEffectAuthority=true (for example settings carried back
// from a newer build) would put parked watchers in fact mode on a
// pty:sideEffect channel that never emits — silent notification loss, the
// failure that sank the first parking attempt. Refusing coverage keeps such
// tabs mounted (status quo). Once the emitter lands, the channel exists and
// this never trips; reconcile by deleting it with #7214.
function wouldParkedWatcherFactModeBeSilent(): boolean {
// Why hydrated-settings-only: park decisions cannot precede settings
// hydration (cold-park hysteresis is 30s), so the pre-hydration sync-read
// path never parks and this check stays a pure "explicitly configured on
// without the emitter" tripwire.
const settings = useAppStore.getState().settings
return (
settings != null &&
settings.terminalMainSideEffectAuthority !== false &&
typeof (globalThis as { window?: Window }).window?.api?.pty?.onSideEffect !== 'function'
)
}
export function canWatcherCoverParkedTerminalTab(
worktreeId: string,
tab: ParkableTerminalTabModel
): boolean {
if (wouldParkedWatcherFactModeBeSilent()) {
return false
}
const panes = resolveParkedTerminalPaneCandidates(tab, useAppStore.getState())
return (
panes.length > 0 &&
panes.every(
(pane) =>
pane.ptyId !== null &&
isTerminalLeafId(pane.leafId) &&
isSnapshotBackedTerminalPty(pane.ptyId, worktreeId)
)
)
}
function startParkedTabWatchers(worktreeId: string, tab: ParkableTerminalTabModel): void {
const state = useAppStore.getState()
const panes = resolveParkedTerminalPaneCandidates(tab, state)
const disposersByPtyId = new Map<string, () => void>()
const paneIdByPtyId = new Map<string, number>()
for (const pane of panes) {
const ptyId = pane.ptyId
// Why: the park policy already excludes non-snapshot-backed PTYs, but the
// tab model can change between the park decision and this effect — guard
// again so remote-runtime/SSH PTYs never get a local watcher. Legacy
// non-UUID leaf ids are skipped because makePaneKey throws on them.
if (
!ptyId ||
disposersByPtyId.has(ptyId) ||
!isTerminalLeafId(pane.leafId) ||
!isSnapshotBackedTerminalPty(ptyId, worktreeId)
) {
continue
}
const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId]
const disposeWatcher = startParkedTerminalByteWatcher({
ptyId,
tabId: tab.id,
worktreeId,
leafId: pane.leafId,
paneId: pane.paneId,
drivesTabTitle: pane.drivesTabTitle,
// Why: seed the watcher's agent tracker with the pane's last known
// title so an agent already working at park time still notifies when
// it finishes while parked.
...(initialTitle !== undefined ? { initialTitle } : {}),
// Why: no pane transport exists while parked; write straight to the
// PTY, the same channel background agent launches use.
sendInput: (data) => window.api.pty.write(ptyId, data)
})
// Why: a PTY that exits while parked has no pane to run exit cleanup; at
// minimum its watcher must not outlive it.
const unsubscribeExit = subscribeToPtyExit(ptyId, () => {
disposersByPtyId.get(ptyId)?.()
disposersByPtyId.delete(ptyId)
// Why: with the last watcher gone there is nothing left to watch or
// dispose; dropping the entry keeps the registry bounded to parked
// tabs that still hold live PTYs.
const entry = parkedWatchersByTabId.get(tab.id)
if (disposersByPtyId.size === 0 && entry?.disposersByPtyId === disposersByPtyId) {
parkedWatchersByTabId.delete(tab.id)
}
})
paneIdByPtyId.set(ptyId, pane.paneId)
disposersByPtyId.set(ptyId, () => {
unsubscribeExit()
disposeWatcher()
})
}
// Why: tracked even with zero watchers so parked-state introspection
// (window.__terminalParkingDebug) reflects every parked tab.
parkedWatchersByTabId.set(tab.id, {
worktreeId,
tabPtyId: tab.ptyId,
paneIdByPtyId,
disposersByPtyId
})
}
/**
* Hosts call this from their onPtyExit handlers before closing the tab.
* Returns true when the close must be deferred: a parked tab has no
* PaneManager to promote split siblings, so the live exit path degenerates to
* "close the whole tab" — which would kill the surviving sibling panes. The
* reveal remount handles dead PTYs per leaf instead. Single-leaf parked tabs
* return false so exit→closeTab parity is preserved. Also clears the dead
* leaf's runtime-title slot so a stale title cannot pin worktree status.
*/
export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): boolean {
const entry = parkedWatchersByTabId.get(tabId)
if (!entry) {
return false
}
const paneId = entry.paneIdByPtyId.get(ptyId)
if (paneId !== undefined) {
useAppStore.getState().clearRuntimePaneTitle(tabId, paneId)
}
const remaining = entry.disposersByPtyId.size
if (remaining === 0) {
return false
}
// Why: this runs from the PTY exit handler, before the exit sidecar above
// removes the dead PTY's watcher — so the watcher count still includes the
// exiting PTY. More than one watcher (or an exit for an unwatched PTY)
// means live sibling leaves remain.
return remaining > 1 || !entry.disposersByPtyId.has(ptyId)
}
/**
* Reconciles watchers for one worktree against its rendered parked set.
* Callers run this from an effect keyed on the committed render state, so
* disposal lands in the same effect flush as a reveal remount (before any
* PTY data IPC can be delivered) and start lands after the park unmount.
*/
export function syncParkedTerminalTabWatchers(args: {
worktreeId: string
tabs: readonly ParkableTerminalTabModel[]
parkedTabIds: ReadonlySet<string>
}): void {
const liveTabIds = new Set(args.tabs.map((tab) => tab.id))
for (const [tabId, entry] of parkedWatchersByTabId) {
if (entry.worktreeId !== args.worktreeId) {
continue
}
if (!args.parkedTabIds.has(tabId) || !liveTabIds.has(tabId)) {
disposeParkedTabWatchers(tabId)
}
}
// Why: captures for closed tabs have no future park/reveal; drop them so
// the registry stays bounded by live tabs.
for (const [tabId, capture] of capturedPanesByTabId) {
if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) {
capturedPanesByTabId.delete(tabId)
}
}
for (const tab of args.tabs) {
if (!args.parkedTabIds.has(tab.id)) {
continue
}
const entry = parkedWatchersByTabId.get(tab.id)
if (entry && entry.tabPtyId !== tab.ptyId) {
disposeParkedTabWatchers(tab.id)
}
if (!parkedWatchersByTabId.has(tab.id)) {
startParkedTabWatchers(args.worktreeId, tab)
}
}
}
@@ -0,0 +1,107 @@
/**
* Parked terminal watcher registry (store-free bookkeeping).
*
* Why a separate module: shutdownWorktreeTerminals (a store slice) must
* synchronously dispose parked watchers, but the watcher lifecycle module
* imports the store — a slice importing it would re-enter store creation
* mid-evaluation. Keeping the maps and pure disposal here lets the slice
* import cycle-free, mirroring how pty-dispatcher exports its handler maps.
*/
export type ParkedTerminalPaneCapture = {
ptyId: string | null
/** PaneManager numeric pane id the live pane used for runtime titles. */
paneId: number
/** Stable terminal-layout leaf UUID (paneKey attribution). */
leafId: string
drivesTabTitle: boolean
}
export type CapturedTabPanes = { worktreeId: string; panes: ParkedTerminalPaneCapture[] }
export const capturedPanesByTabId = new Map<string, CapturedTabPanes>()
// Why: PaneManager pane ids die with the unmounted pane, but the watcher must
// keep writing the exact runtime-title slots the live pane used — a different
// slot would strand a stale "working" title that pins worktree status.
// TerminalPane unmount records the identities here for the park wiring.
export function captureParkedTerminalPaneCandidates(
tabId: string,
worktreeId: string,
panes: ParkedTerminalPaneCapture[]
): void {
capturedPanesByTabId.set(tabId, { worktreeId, panes })
}
export type ParkedTabWatcherEntry = {
worktreeId: string
/** Tab-level ptyId at watcher start; a change means the PTY was re-minted
* (e.g. wake respawn) and the watchers must restart against fresh ids. */
tabPtyId: string | null
/** Runtime-title slot each watcher writes, so parked PTY-exit handling can
* clear the dead leaf's slot (no live pane will ever overwrite it). */
paneIdByPtyId: Map<string, number>
disposersByPtyId: Map<string, () => void>
}
export const parkedWatchersByTabId = new Map<string, ParkedTabWatcherEntry>()
export function getParkedTerminalWatcherTabIds(): string[] {
return Array.from(parkedWatchersByTabId.keys())
}
export function disposeParkedTabWatchers(tabId: string): void {
const entry = parkedWatchersByTabId.get(tabId)
if (!entry) {
return
}
parkedWatchersByTabId.delete(tabId)
for (const dispose of entry.disposersByPtyId.values()) {
dispose()
}
entry.disposersByPtyId.clear()
}
/**
* Synchronously disposes any parked watcher subscribed to these PTYs.
* shutdownWorktreeTerminals silences the live transports' final teardown
* flush via unregisterPtyDataHandlers, but parked watchers ride the
* dispatcher SIDECAR channel that call does not touch — without this, the
* flush still marks unread and arms notification timers for a worktree that
* is already sleeping or deleted. The tab entries are kept so a sleeping
* parked tab does not restart watchers against its stale PTY ids; wake
* re-mints the ids and the sync path restarts watchers then.
*/
export function disposeParkedTerminalWatchersForPtyIds(ptyIds: readonly string[]): void {
for (const entry of parkedWatchersByTabId.values()) {
for (const ptyId of ptyIds) {
const dispose = entry.disposersByPtyId.get(ptyId)
if (dispose) {
entry.disposersByPtyId.delete(ptyId)
dispose()
}
}
}
}
export function disposeParkedTerminalWatchersForWorktree(worktreeId: string): void {
for (const [tabId, entry] of parkedWatchersByTabId) {
if (entry.worktreeId === worktreeId) {
disposeParkedTabWatchers(tabId)
}
}
}
/** Drops watchers and captures for worktrees that no longer exist. */
export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet<string>): void {
for (const [tabId, entry] of parkedWatchersByTabId) {
if (!liveWorktreeIds.has(entry.worktreeId)) {
disposeParkedTabWatchers(tabId)
}
}
for (const [tabId, capture] of capturedPanesByTabId) {
if (!liveWorktreeIds.has(capture.worktreeId)) {
capturedPanesByTabId.delete(tabId)
}
}
}
@@ -0,0 +1,138 @@
/**
* Byte-mode pinning guard for the standalone hidden-view-parking extraction.
*
* This build ships NO main-process side-effect emitter (that arrives with PR
* #7214), so the parked-tab watcher must always run in byte-parser mode. If
* terminalMainSideEffectAuthority ever resolves true here, the watcher would
* consume a pty:sideEffect channel that never emits and parked tabs would
* silently lose bell/title/completion notifications — the exact failure that
* got the first parking attempt reverted. These tests pin the three levers:
* the shipped defaults, the mode predicates under those defaults, and the
* park-eligibility tripwire for misconfigured (authority-on) settings.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
const WORKTREE_ID = 'repo::/worktree'
const TAB_ID = 'guard-tab-1'
const PTY_ID = `${WORKTREE_ID}@@session-guard`
const LEAF_ID = '31111111-1111-4111-8111-111111111111'
vi.mock('./parked-terminal-byte-watcher', () => ({
startParkedTerminalByteWatcher: (_options: ParkedTerminalByteWatcherOptions) => vi.fn()
}))
vi.mock('./pty-dispatcher', () => ({
subscribeToPtyExit: () => vi.fn()
}))
type MockStoreState = {
settings?: { terminalMainSideEffectAuthority?: boolean } | null
terminalLayoutsByTabId: Record<
string,
{
root: unknown
activeLeafId: string | null
expandedLeafId: string | null
ptyIdsByLeafId?: Record<string, string>
}
>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
}
let mockStoreState: MockStoreState
vi.mock('@/store', () => ({
useAppStore: { getState: () => mockStoreState }
}))
import {
isMainTerminalSideEffectAuthorityForPty,
_resetTerminalSideEffectFactConsumersForTest
} from './terminal-side-effect-facts-handler'
import {
isRendererHiddenPtyDeliveryGateEnabled,
_resetHiddenPtyDeliveryGateFlagCacheForTest
} from './terminal-hidden-delivery-gate'
import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers'
const originalWindow = (globalThis as { window?: unknown }).window
function setWindowApi(api: unknown): void {
;(globalThis as { window?: unknown }).window = api === undefined ? undefined : { api }
}
beforeEach(() => {
_resetTerminalSideEffectFactConsumersForTest()
_resetHiddenPtyDeliveryGateFlagCacheForTest()
mockStoreState = {
settings: null,
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
}
},
runtimePaneTitlesByTabId: {},
clearRuntimePaneTitle: vi.fn()
}
})
afterEach(() => {
;(globalThis as { window?: unknown }).window = originalWindow
_resetTerminalSideEffectFactConsumersForTest()
_resetHiddenPtyDeliveryGateFlagCacheForTest()
})
describe('shipped defaults pin byte mode', () => {
it('ships all four terminal authority/gate/parking flags OFF', () => {
const defaults = getDefaultSettings('/home/guard-test')
expect(defaults.terminalHiddenViewParking).toBe(false)
expect(defaults.terminalMainSideEffectAuthority).toBe(false)
expect(defaults.terminalHiddenDeliveryGate).toBe(false)
expect(defaults.terminalModelQueryAuthority).toBe(false)
})
it('selects byte-parser mode for parked watchers under the shipped defaults', () => {
// The watcher's mode ternary is exactly this predicate: false → byte mode
// (registers byte parsers), true → fact mode (dead channel in this build).
expect(
isMainTerminalSideEffectAuthorityForPty({
settings: getDefaultSettings('/home/guard-test'),
runtimeEnvironmentId: null
})
).toBe(false)
})
it('keeps the hidden-delivery gate off under the shipped defaults', () => {
expect(isRendererHiddenPtyDeliveryGateEnabled(getDefaultSettings('/home/guard-test'))).toBe(
false
)
})
})
describe('fact mode requires the pty:sideEffect channel', () => {
it('refuses park coverage when authority is configured on without the channel', () => {
// Misconfiguration scenario: settings carried back from a build that had
// the main emitter (authority=true) running on this emitter-less build.
mockStoreState.settings = { terminalMainSideEffectAuthority: true }
setWindowApi({ pty: {} })
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(false)
})
it('allows park coverage for authority-on settings once the channel exists', () => {
mockStoreState.settings = { terminalMainSideEffectAuthority: true }
setWindowApi({ pty: { onSideEffect: () => () => {} } })
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(true)
})
it('allows park coverage in byte mode without any channel', () => {
mockStoreState.settings = { terminalMainSideEffectAuthority: false }
setWindowApi({ pty: {} })
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(true)
})
})
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type MockE2EConfig = { exposeStore: boolean; terminalParkingDelayMs: number | null }
let mockE2EConfig: MockE2EConfig
vi.mock('@/lib/e2e-config', () => ({
get e2eConfig() {
return mockE2EConfig
}
}))
vi.mock('./terminal-parked-tab-watchers', () => ({
getParkedTerminalWatcherTabIds: () => ['tab-parked']
}))
const originalWindow = (globalThis as { window?: unknown }).window
type TerminalParkingE2EOverridesModule = {
getTerminalParkingPolicyOverrides: () => {
coldParkDelayMs?: number
hotRetainMs?: number
hotRetainLimit?: number
}
registerTerminalParkingDebugHandle: () => void
}
async function importOverridesModule(): Promise<TerminalParkingE2EOverridesModule> {
vi.resetModules()
return import('./terminal-parking-e2e-overrides')
}
describe('getTerminalParkingPolicyOverrides', () => {
beforeEach(() => {
mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null }
delete (globalThis as { window?: unknown }).window
})
afterEach(() => {
;(globalThis as { window?: unknown }).window = originalWindow
})
it('ignores the delay override outside e2e (exposeStore off)', async () => {
mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: 500 }
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
expect(getTerminalParkingPolicyOverrides()).toEqual({})
})
it('maps the e2e delay to BOTH coldParkDelayMs and hotRetainMs', async () => {
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 }
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
expect(getTerminalParkingPolicyOverrides()).toEqual({
coldParkDelayMs: 500,
hotRetainMs: 500
})
})
it('returns no overrides when no delay is configured', async () => {
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null }
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
expect(getTerminalParkingPolicyOverrides()).toEqual({})
})
it('registers window.__terminalParkingDebug on import under exposeStore', async () => {
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 }
const testWindow: {
__terminalParkingDebug?: { parkDelayMs: number; parkedTabIds: () => string[] }
} = {}
;(globalThis as { window?: unknown }).window = testWindow
await importOverridesModule()
expect(testWindow.__terminalParkingDebug?.parkDelayMs).toBe(500)
expect(testWindow.__terminalParkingDebug?.parkedTabIds()).toEqual(['tab-parked'])
})
it('does not register the debug handle outside e2e', async () => {
mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null }
const testWindow: { __terminalParkingDebug?: unknown } = {}
;(globalThis as { window?: unknown }).window = testWindow
await importOverridesModule()
expect(testWindow.__terminalParkingDebug).toBeUndefined()
})
})
@@ -0,0 +1,34 @@
import { e2eConfig } from '@/lib/e2e-config'
import {
TERMINAL_TAB_COLD_PARK_DELAY_MS,
type TerminalColdParkPolicyOverrides
} from './terminal-hidden-view-parking'
import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers'
// Why: ORCA_E2E_TERMINAL_PARKING_DELAY_MS must shrink BOTH the cold-park
// hysteresis and the hot-retain window — recently hidden tabs otherwise sit
// in the hot-retain working set for 5 minutes and never park within a test
// run. Gated on exposeStore so packaged builds ignore stray env vars.
export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverrides {
const delayMs = e2eConfig.exposeStore ? e2eConfig.terminalParkingDelayMs : null
return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0
? { coldParkDelayMs: delayMs, hotRetainMs: delayMs }
: {}
}
export function registerTerminalParkingDebugHandle(): void {
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
return
}
window.__terminalParkingDebug = {
parkDelayMs:
getTerminalParkingPolicyOverrides().coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
parkedTabIds: () => getParkedTerminalWatcherTabIds()
}
}
// Why: the parking e2e spec gates on window.__terminalParkingDebug existing
// shortly after launch. This module is statically imported by the park
// wiring, so registering at module load makes the handle visible before any
// tab parks.
registerTerminalParkingDebugHandle()
@@ -0,0 +1,509 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalSideEffectBatch } from '../../../../shared/terminal-side-effect-facts'
import {
_dispatchTerminalSideEffectBatchForTest,
_resetTerminalSideEffectFactConsumersForTest,
isMainTerminalSideEffectAuthorityForPty,
registerTerminalSideEffectFactConsumer,
type TerminalSideEffectFactConsumerCallbacks
} from './terminal-side-effect-facts-handler'
const PTY_ID = 'wt-1#1'
function createCallbackRecorder(): {
callbacks: TerminalSideEffectFactConsumerCallbacks
events: unknown[][]
} {
const events: unknown[][] = []
return {
events,
callbacks: {
onTitleChange: (normalizedTitle, rawTitle) =>
events.push(['title', normalizedTitle, rawTitle]),
onBell: () => events.push(['bell']),
onAgentBecameIdle: (title) => events.push(['idle', title]),
onAgentBecameWorking: () => events.push(['working']),
onAgentExited: () => events.push(['exited'])
}
}
}
function batch(
facts: TerminalSideEffectBatch['facts'],
options: Partial<TerminalSideEffectBatch> = {}
): TerminalSideEffectBatch {
return { ptyId: PTY_ID, seq: 0, facts, ...options }
}
describe('isMainTerminalSideEffectAuthorityForPty', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
beforeEach(() => {
_resetTerminalSideEffectFactConsumersForTest()
delete (globalThis as { window?: unknown }).window
})
afterEach(() => {
_resetTerminalSideEffectFactConsumersForTest()
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
} else {
delete (globalThis as { window?: typeof window }).window
}
})
function setPersistedSettingsSync(settings: unknown): void {
;(globalThis as { window: unknown }).window = {
api: { settings: { getSync: () => settings } }
}
}
it('is on by default for PTYs whose bytes transit local main', () => {
expect(
isMainTerminalSideEffectAuthorityForPty({ settings: {}, runtimeEnvironmentId: null })
).toBe(true)
// Why: settings hydrate asynchronously; the default-on switch must not
// flip authority off during the null-settings startup window.
expect(
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
).toBe(true)
})
it('is off for remote-runtime PTYs regardless of the setting', () => {
expect(
isMainTerminalSideEffectAuthorityForPty({
settings: { terminalMainSideEffectAuthority: true },
runtimeEnvironmentId: 'env-1'
})
).toBe(false)
})
it('is off when the kill switch is disabled', () => {
expect(
isMainTerminalSideEffectAuthorityForPty({
settings: { terminalMainSideEffectAuthority: false },
runtimeEnvironmentId: null
})
).toBe(false)
})
it('honors the persisted kill switch before settings hydrate', () => {
// Why: the authority decision is made once at transport creation; a pane
// bound during startup must not pick main authority when the user
// persisted the switch off.
setPersistedSettingsSync({ terminalMainSideEffectAuthority: false })
expect(
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
).toBe(false)
})
it('stays on pre-hydration when the persisted switch is on or unset', () => {
setPersistedSettingsSync({ terminalMainSideEffectAuthority: true })
expect(
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
).toBe(true)
_resetTerminalSideEffectFactConsumersForTest()
setPersistedSettingsSync({})
expect(
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
).toBe(true)
})
it('prefers hydrated settings over the persisted sync read', () => {
setPersistedSettingsSync({ terminalMainSideEffectAuthority: false })
expect(
isMainTerminalSideEffectAuthorityForPty({
settings: { terminalMainSideEffectAuthority: true },
runtimeEnvironmentId: null
})
).toBe(true)
})
it('caches the sync read so panes do not re-block per bind', () => {
const getSync = vi.fn(() => ({ terminalMainSideEffectAuthority: false }))
;(globalThis as { window: unknown }).window = { api: { settings: { getSync } } }
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
expect(getSync).toHaveBeenCalledTimes(1)
})
})
describe('registerTerminalSideEffectFactConsumer', () => {
const originalWindow = (globalThis as { window?: typeof window }).window
beforeEach(() => {
_resetTerminalSideEffectFactConsumersForTest()
delete (globalThis as { window?: unknown }).window
})
afterEach(() => {
_resetTerminalSideEffectFactConsumersForTest()
if (originalWindow) {
;(globalThis as { window: typeof window }).window = originalWindow
} else {
delete (globalThis as { window?: typeof window }).window
}
})
it('routes live facts to the registered consumer in batch order', () => {
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
_dispatchTerminalSideEffectBatchForTest(
batch([
{ kind: 'title', normalizedTitle: '⠋ Claude', rawTitle: '⠋ Claude' },
{ kind: 'agent-working' },
{ kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' },
{ kind: 'agent-idle', title: '✳ Claude' },
{ kind: 'bell' }
])
)
expect(events).toEqual([
['title', '⠋ Claude', '⠋ Claude'],
['working'],
['title', '✳ Claude', '✳ Claude'],
['idle', '✳ Claude'],
['bell']
])
})
it('routes command-finished and pr-link facts to the registered consumer', () => {
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onCommandFinished: (exitCode) => events.push(['finished', exitCode]),
onPrLink: (link) => events.push(['pr', link.url, link.number])
}
})
_dispatchTerminalSideEffectBatchForTest(
batch([
{ kind: 'command-finished', exitCode: 130 },
{
kind: 'pr-link',
link: {
url: 'https://github.com/acme/orca/pull/42',
slug: { owner: 'acme', repo: 'orca' },
number: 42
}
},
{ kind: 'command-finished', exitCode: null }
])
)
expect(events).toEqual([
['finished', 130],
['pr', 'https://github.com/acme/orca/pull/42', 42],
['finished', null]
])
})
it('routes command-code scrape facts to the registered consumer', () => {
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]),
onCommandCodeDone: (prompt) => events.push(['cc-done', prompt])
}
})
_dispatchTerminalSideEffectBatchForTest(
batch([
{ kind: 'command-code-working', prompt: 'Fix the spinner' },
{ kind: 'command-code-done', prompt: 'Fix the spinner' }
])
)
expect(events).toEqual([
['cc-working', 'Fix the spinner'],
['cc-done', 'Fix the spinner']
])
})
it('never replays command-code scrape facts', () => {
// Why: a replayed working/done seed would resurrect a finished turn's
// status row — replay batches restore title state only.
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]),
onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]),
onCommandCodeDone: (prompt) => events.push(['cc-done', prompt])
}
})
_dispatchTerminalSideEffectBatchForTest(
batch(
[
{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' },
{ kind: 'command-code-working', prompt: 'Fix the spinner' },
{ kind: 'command-code-done', prompt: 'Fix the spinner' }
],
{ replay: true, seq: 5 }
)
)
expect(events).toEqual([['title', 'restored']])
})
it('routes 2031-subscribe facts to the registered consumer but never replays them', () => {
// Why: the fact lets hidden-delivery-gated views answer the color-scheme
// query without byte access; a replayed subscribe would re-answer a query
// the snapshot already satisfied.
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]),
onMode2031Subscribe: () => events.push(['2031-subscribe'])
}
})
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: '2031-subscribe' }]))
expect(events).toEqual([['2031-subscribe']])
_dispatchTerminalSideEffectBatchForTest(
batch(
[
{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' },
{ kind: '2031-subscribe' }
],
{ replay: true, seq: 5 }
)
)
expect(events).toEqual([['2031-subscribe'], ['title', 'restored']])
})
it('never replays command-finished or pr-link facts', () => {
// Why: like bells and agent transitions, command/PR facts are attention
// signals — replay snapshots restore title state only.
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]),
onCommandFinished: (exitCode) => events.push(['finished', exitCode]),
onPrLink: (link) => events.push(['pr', link.url])
}
})
_dispatchTerminalSideEffectBatchForTest(
batch(
[
{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' },
{ kind: 'command-finished', exitCode: 0 },
{
kind: 'pr-link',
link: {
url: 'https://github.com/acme/orca/pull/42',
slug: { owner: 'acme', repo: 'orca' },
number: 42
}
}
],
{ replay: true, seq: 5 }
)
)
expect(events).toEqual([['title', 'restored']])
})
it('passes stale-clear provenance through to the title and idle callbacks', () => {
const events: unknown[][] = []
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: {
onTitleChange: (normalizedTitle, _rawTitle, meta) =>
events.push(['title', normalizedTitle, meta]),
onAgentBecameIdle: (title, meta) => events.push(['idle', title, meta])
}
})
_dispatchTerminalSideEffectBatchForTest(
batch([
{ kind: 'agent-idle', title: 'Codex done' },
{
kind: 'title',
normalizedTitle: 'Codex',
rawTitle: 'Codex',
staleWorkingTitleClear: true
},
{ kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true }
])
)
expect(events).toEqual([
['idle', 'Codex done', undefined],
['title', 'Codex', { staleWorkingTitleClear: true }],
['idle', 'Codex', { staleWorkingTitleClear: true }]
])
})
it('drops batches for PTYs without a registered consumer', () => {
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { ptyId: 'other-pty' }))
expect(events).toEqual([])
})
it('applies only title facts from replay batches — no attention replay', () => {
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
_dispatchTerminalSideEffectBatchForTest(
batch(
[
{ kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' },
{ kind: 'bell' },
{ kind: 'agent-idle', title: '✳ Claude' }
],
{ replay: true, seq: 10 }
)
)
expect(events).toEqual([['title', '✳ Claude', '✳ Claude']])
})
it('drops a replay title not newer than the last applied live title', () => {
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
_dispatchTerminalSideEffectBatchForTest(
batch([{ kind: 'title', normalizedTitle: 'live', rawTitle: 'live' }], { seq: 20 })
)
_dispatchTerminalSideEffectBatchForTest(
batch([{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }], {
replay: true,
seq: 20
})
)
_dispatchTerminalSideEffectBatchForTest(
batch([{ kind: 'title', normalizedTitle: 'newer', rawTitle: 'newer' }], {
replay: true,
seq: 21
})
)
expect(events).toEqual([
['title', 'live', 'live'],
['title', 'newer', 'newer']
])
})
it('keeps exactly one consumer per PTY: a new registration replaces the old', () => {
const first = createCallbackRecorder()
const second = createCallbackRecorder()
const disposeFirst = registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: first.callbacks
})
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks })
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
// A stale registration's dispose must not evict the live consumer.
disposeFirst()
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
expect(first.events).toEqual([])
expect(second.events).toEqual([['bell'], ['bell']])
})
it('stops routing after the consumer unregisters', () => {
const { callbacks, events } = createCallbackRecorder()
const dispose = registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
dispose()
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
expect(events).toEqual([])
})
it('subscribes to the channel once and routes IPC batches', () => {
let channelCallback: ((batch: TerminalSideEffectBatch) => void) | null = null
const onSideEffect = vi.fn((callback: (batch: TerminalSideEffectBatch) => void) => {
channelCallback = callback
return () => {
channelCallback = null
}
})
;(globalThis as { window: unknown }).window = {
api: { pty: { onSideEffect } }
}
const first = createCallbackRecorder()
const second = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: first.callbacks })
registerTerminalSideEffectFactConsumer({ ptyId: 'pty-2', callbacks: second.callbacks })
expect(onSideEffect).toHaveBeenCalledTimes(1)
channelCallback!(batch([{ kind: 'bell' }], { ptyId: 'pty-2' }))
expect(second.events).toEqual([['bell']])
})
it('applies the title snapshot on register unless the registration was replaced', async () => {
let resolveSnapshot: (value: TerminalSideEffectBatch | null) => void = () => {}
const getSideEffectSnapshot = vi.fn(
() =>
new Promise<TerminalSideEffectBatch | null>((resolve) => {
resolveSnapshot = resolve
})
)
;(globalThis as { window: unknown }).window = {
api: { pty: { getSideEffectSnapshot } }
}
const first = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks: first.callbacks,
restoreTitleOnRegister: true
})
expect(getSideEffectSnapshot).toHaveBeenCalledWith(PTY_ID)
// Replace before the snapshot resolves: the slow snapshot must not fire
// into the superseded registration.
const second = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks })
resolveSnapshot(
batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], {
replay: true,
seq: 5
})
)
await Promise.resolve()
expect(first.events).toEqual([])
expect(second.events).toEqual([])
})
it('restores the snapshot title for a live registration', async () => {
const snapshot = batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], {
replay: true,
seq: 5
})
;(globalThis as { window: unknown }).window = {
api: { pty: { getSideEffectSnapshot: vi.fn(async () => snapshot) } }
}
const { callbacks, events } = createCallbackRecorder()
registerTerminalSideEffectFactConsumer({
ptyId: PTY_ID,
callbacks,
restoreTitleOnRegister: true
})
await Promise.resolve()
await Promise.resolve()
expect(events).toEqual([['title', 'restored', 'restored']])
})
})
@@ -0,0 +1,242 @@
/**
* Renderer consumer registry for the `pty:sideEffect` channel.
*
* Why: with main as the side-effect parser for local-daemon/SSH PTYs
* (docs/reference/terminal-side-effect-authority.md), the renderer no longer
* derives title/bell/agent facts from bytes for those PTYs. This module is
* the single channel subscriber; mounted panes and parked-tab watchers
* register exactly one fact consumer per PTY (their existing policy
* callbacks), so every fact has exactly one policy consumer regardless of
* whether the tab is mounted, hidden, or parked. Facts for PTYs without a
* registered consumer are dropped — mirroring today's eager-buffer behavior
* where pre-mount output produces no attention side effects.
*/
import type { GlobalSettings } from '../../../../shared/types'
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
import type {
TerminalSideEffectBatch,
TerminalSideEffectFact
} from '../../../../shared/terminal-side-effect-facts'
// Why: cached once per session — the blocking read should only ever run on
// the pre-hydration startup path, never per pane bind.
let persistedAuthorityFlagCache: boolean | null | undefined
function readPersistedSideEffectAuthorityFlagSync(): boolean | null {
if (persistedAuthorityFlagCache === undefined) {
try {
const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync
persistedAuthorityFlagCache =
typeof getSync === 'function' ? (getSync()?.terminalMainSideEffectAuthority ?? null) : null
} catch {
persistedAuthorityFlagCache = null
}
}
return persistedAuthorityFlagCache
}
/**
* Structural authority predicate: main owns side effects for a PTY when its
* bytes transit local main (everything except remote-runtime PTYs) and the
* kill switch is on. Decided at transport/watcher creation — never per chunk —
* so each fact has one consumer with no race.
*/
export function isMainTerminalSideEffectAuthorityForPty(args: {
settings: Pick<GlobalSettings, 'terminalMainSideEffectAuthority'> | null
/** Remote-runtime owner environment; null means bytes transit local main. */
runtimeEnvironmentId: string | null
}): boolean {
if (args.runtimeEnvironmentId !== null) {
return false
}
if (args.settings !== null) {
return args.settings.terminalMainSideEffectAuthority !== false
}
// Why: settings hydrate asynchronously, and the authority decision made
// here at transport/watcher creation is never revisited. A pane bound
// before hydration must honor the persisted kill switch — otherwise a user
// who turned main authority off gets startup panes with no byte parsers
// and a fact consumer they disabled. Surfaces without the sync read (web
// remote clients, tests) keep the default-on behavior.
return readPersistedSideEffectAuthorityFlagSync() !== false
}
export type TerminalSideEffectFactConsumerCallbacks = {
/** `meta.staleWorkingTitleClear` marks facts derived from main's 3s
* stale-title timer — policy must clear title/cache state without
* scheduling task-complete notifications or unread attention. */
onTitleChange?: (
normalizedTitle: string,
rawTitle: string,
meta?: { staleWorkingTitleClear?: boolean }
) => void
onBell?: () => void
onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void
onAgentBecameWorking?: () => void
onAgentExited?: () => void
/** OSC 133;D — same policy hook the byte-mode commandLifecycle drove
* (stale agent-status row drop + interrupt-inference coordination). */
onCommandFinished?: (bestEffortExitCode: number | null) => void
onPrLink?: (link: TerminalGitHubPRLink) => void
/** Command Code output scrape (no hooks): working seeds the status row;
* done is settle-checked by the pane policy before completing the turn. */
onCommandCodeWorking?: (prompt: string) => void
onCommandCodeDone?: (prompt: string) => void
/** DECSET 2031 subscribe observed by main's tracker. Registered only by
* hidden-delivery-gated consumers (their bytes never arrive); the theme
* reply is sent renderer-side — query authority stays with the view. */
onMode2031Subscribe?: () => void
}
type ConsumerEntry = {
callbacks: TerminalSideEffectFactConsumerCallbacks
/** Output sequence of the last live title fact applied. Replay snapshots at
* or before this point are stale and must not regress the title state. */
lastLiveTitleSeq: number | null
}
const consumersByPtyId = new Map<string, ConsumerEntry>()
let channelUnsubscribe: (() => void) | null = null
function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void {
switch (fact.kind) {
case 'title':
entry.lastLiveTitleSeq = seq
entry.callbacks.onTitleChange?.(
fact.normalizedTitle,
fact.rawTitle,
fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined
)
return
case 'bell':
entry.callbacks.onBell?.()
return
case 'agent-working':
entry.callbacks.onAgentBecameWorking?.()
return
case 'agent-idle':
entry.callbacks.onAgentBecameIdle?.(
fact.title,
fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined
)
return
case 'agent-exited':
entry.callbacks.onAgentExited?.()
return
case 'command-finished':
entry.callbacks.onCommandFinished?.(fact.exitCode)
return
case 'pr-link':
entry.callbacks.onPrLink?.(fact.link)
return
case 'command-code-working':
entry.callbacks.onCommandCodeWorking?.(fact.prompt)
return
case 'command-code-done':
entry.callbacks.onCommandCodeDone?.(fact.prompt)
return
case '2031-subscribe':
entry.callbacks.onMode2031Subscribe?.()
}
}
function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBatch): void {
if (batch.replay) {
// Why: the no-attention-replay rule — (re)attach snapshots restore title
// state only; historical bells/completions must never fire again. A replay
// older (by output sequence) than the last live title fact is stale.
if (entry.lastLiveTitleSeq !== null && batch.seq <= entry.lastLiveTitleSeq) {
return
}
for (const fact of batch.facts) {
if (fact.kind === 'title') {
entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle)
}
}
return
}
for (const fact of batch.facts) {
applyLiveFact(entry, fact, batch.seq)
}
}
function handleSideEffectBatch(batch: TerminalSideEffectBatch): void {
const entry = consumersByPtyId.get(batch.ptyId)
if (!entry) {
return
}
applyBatchToConsumer(entry, batch)
}
function ensureSideEffectChannelSubscription(): void {
if (channelUnsubscribe !== null) {
return
}
// Why: optional-chained from globalThis so unit tests (and any non-preload
// surface) without window.api degrade to "no channel" instead of throwing.
const onSideEffect = (globalThis as { window?: Window }).window?.api?.pty?.onSideEffect
if (typeof onSideEffect !== 'function') {
return
}
channelUnsubscribe = onSideEffect(handleSideEffectBatch)
}
export type TerminalSideEffectFactConsumerOptions = {
ptyId: string
callbacks: TerminalSideEffectFactConsumerCallbacks
/** Pull main's title-only replay snapshot on registration. Pane transports
* use this in place of deriving titles from eager-buffer byte replay;
* parked watchers skip it because the pane's runtime title slot is already
* current at park time. */
restoreTitleOnRegister?: boolean
}
/**
* Register the single fact consumer for a PTY. A new registration replaces a
* stale one for the same PTY (same semantics as the parked watcher registry):
* two consumers would double-fire bell/completion policy for the same bytes.
*/
export function registerTerminalSideEffectFactConsumer(
options: TerminalSideEffectFactConsumerOptions
): () => void {
ensureSideEffectChannelSubscription()
const entry: ConsumerEntry = {
callbacks: options.callbacks,
lastLiveTitleSeq: null
}
consumersByPtyId.set(options.ptyId, entry)
if (options.restoreTitleOnRegister) {
const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot
if (typeof getSnapshot === 'function') {
void getSnapshot(options.ptyId)
.then((batch) => {
// Why: apply only while this registration is still the live
// consumer; a slow snapshot must not fire into a replaced one.
if (batch && consumersByPtyId.get(options.ptyId) === entry) {
applyBatchToConsumer(entry, { ...batch, replay: true })
}
})
.catch(() => {})
}
}
return () => {
if (consumersByPtyId.get(options.ptyId) === entry) {
consumersByPtyId.delete(options.ptyId)
}
}
}
/** Test seam: deliver a batch as if it arrived on the channel. */
export function _dispatchTerminalSideEffectBatchForTest(batch: TerminalSideEffectBatch): void {
handleSideEffectBatch(batch)
}
/** Test seam: reset module state between tests. */
export function _resetTerminalSideEffectFactConsumersForTest(): void {
consumersByPtyId.clear()
channelUnsubscribe?.()
channelUnsubscribe = null
persistedAuthorityFlagCache = undefined
}
@@ -97,6 +97,7 @@ import {
syncTerminalScrollIntentSoon
} from '@/lib/pane-manager/terminal-scroll-intent'
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers'
import { e2eConfig } from '@/lib/e2e-config'
import {
PRIMARY_SELECTION_MAX_LENGTH,
@@ -1656,6 +1657,19 @@ export function useTerminalPaneLifecycle({
disposable.dispose()
}
imeNativeTextForwarderDisposables.clear()
// Why: hidden-view parking starts pane-less byte watchers right after
// this unmount; record pane identities before transports detach so the
// watchers write the same runtime-title slots the live panes used.
captureParkedTerminalPaneCandidates(
tabId,
worktreeId,
manager.getPanes().map((capturedPane) => ({
ptyId: paneTransports.get(capturedPane.id)?.getPtyId() ?? null,
paneId: capturedPane.id,
leafId: capturedPane.leafId,
drivesTabTitle: manager.getActivePane()?.id === capturedPane.id
}))
)
for (const transport of paneTransports.values()) {
const ptyId = transport.getPtyId()
if (
@@ -0,0 +1,243 @@
/**
* Per-tab hidden-view parking for TerminalPaneOverlayLayer.
*
* Why: owns the cold-park policy bookkeeping (hiddenSince tracking, recheck
* timers, parked-set selection) and the parked byte-watcher reconciliation so
* the overlay layer only consumes the final parked tab set when deciding to
* render a slot as null. See docs/reference/terminal-hidden-view-parking.md.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import type { TerminalTab } from '../../../../shared/types'
import { useAppStore } from '../../store'
import {
findActivityTerminalPortal,
type ActivityTerminalPortalTarget
} from '../activity/activity-terminal-portal'
import {
getTerminalTabColdParkRecheckDelayMs,
selectColdParkedTerminalTabs,
type TerminalTabColdParkCandidate
} from './terminal-hidden-view-parking'
import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides'
import {
canWatcherCoverParkedTerminalTab,
disposeParkedTerminalWatchersForWorktree,
syncParkedTerminalTabWatchers
} from './terminal-parked-tab-watchers'
type TerminalOverlayTabAssignment = {
groupId: string
isActiveInGroup: boolean
}
function haveSameTerminalTabIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
if (left.size !== right.size) {
return false
}
for (const id of left) {
if (!right.has(id)) {
return false
}
}
return true
}
export function useTerminalTabColdParking(args: {
worktreeId: string
terminalTabs: readonly TerminalTab[]
assignments: ReadonlyMap<string, TerminalOverlayTabAssignment>
isWorktreeActive: boolean
/** Worktree-level park verdict from Terminal.tsx. */
coldParkTerminalPanes: boolean
/** Hidden-measuring startup probe from Terminal.tsx — the panes must stay
* mounted for their first xterm fit, mirroring the worktree-level guard. */
shouldMeasureHiddenWorktree: boolean
activityTerminalPortals: ActivityTerminalPortalTarget[]
}): ReadonlySet<string> {
const {
worktreeId,
terminalTabs,
assignments,
isWorktreeActive,
coldParkTerminalPanes,
shouldMeasureHiddenWorktree,
activityTerminalPortals
} = args
const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId)
const terminalParkingEnabled = useAppStore(
(state) => state.settings?.terminalHiddenViewParking !== false
)
const terminalTabHiddenSinceRef = useRef(new Map<string, number>())
const terminalTabParkingTimersRef = useRef(new Map<string, number>())
const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0)
const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState<ReadonlySet<string>>(
() => new Set()
)
useEffect(() => {
const timers = terminalTabParkingTimersRef.current
return () => {
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
}
}, [])
// Why: per-tab cold-park policy — hiddenSince bookkeeping, parked-set
// selection, and one recheck timer per still-pending deadline so React
// re-renders exactly when the hysteresis elapses instead of polling.
useEffect(() => {
const timers = terminalTabParkingTimersRef.current
for (const timer of timers.values()) {
window.clearTimeout(timer)
}
timers.clear()
const nowMs = Date.now()
const overrides = getTerminalParkingPolicyOverrides()
const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id))
const portalTabIds = new Set(
activityTerminalPortals
.filter((portal) => portal.worktreeId === worktreeId)
.map((portal) => portal.tabId)
)
for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) {
if (!currentTerminalTabIds.has(tabId)) {
terminalTabHiddenSinceRef.current.delete(tabId)
}
}
const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => {
const assignment = assignments.get(terminalTab.id)
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id)
// Why: hidden-measuring counts as visibility — the startup probe needs
// mounted panes, so the hidden clock must not run during it.
if (isVisible || hasActivityTerminalPortal || shouldMeasureHiddenWorktree) {
terminalTabHiddenSinceRef.current.delete(terminalTab.id)
} else if (!terminalTabHiddenSinceRef.current.has(terminalTab.id)) {
terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs)
}
return {
id: terminalTab.id,
ptyId: terminalTab.ptyId,
pendingActivationSpawn: terminalTab.pendingActivationSpawn,
isVisible,
hasActivityTerminalPortal,
hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null
}
})
const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({
worktreeId,
terminalTabs: candidates,
pendingStartupByTabId,
parkingEnabled: terminalParkingEnabled,
nowMs,
...overrides
})
// Why: a tab the byte watchers cannot cover (no capture, no layout
// snapshot, legacy leaf ids) must never park — it would go silent for
// bells/titles/completions, the failure that sank the first attempt.
for (const terminalTab of terminalTabs) {
if (
nextColdParkedTerminalTabIds.has(terminalTab.id) &&
!canWatcherCoverParkedTerminalTab(worktreeId, terminalTab)
) {
nextColdParkedTerminalTabIds.delete(terminalTab.id)
}
}
setColdParkedTerminalTabIds((current) =>
haveSameTerminalTabIds(current, nextColdParkedTerminalTabIds)
? current
: nextColdParkedTerminalTabIds
)
for (const candidate of candidates) {
if (
candidate.isVisible ||
candidate.hasActivityTerminalPortal ||
nextColdParkedTerminalTabIds.has(candidate.id)
) {
continue
}
const delayMs = getTerminalTabColdParkRecheckDelayMs({
parkingEnabled: terminalParkingEnabled,
hiddenSinceMs: candidate.hiddenSinceMs,
nowMs,
...overrides
})
if (delayMs !== null && delayMs > 0) {
const tabId = candidate.id
const timer = window.setTimeout(() => {
timers.delete(tabId)
setTerminalTabParkingRevision((revision) => revision + 1)
}, delayMs)
timers.set(tabId, timer)
}
}
}, [
activityTerminalPortals,
assignments,
isWorktreeActive,
pendingStartupByTabId,
shouldMeasureHiddenWorktree,
terminalParkingEnabled,
terminalTabParkingRevision,
terminalTabs,
worktreeId
])
// Why: the rendered park verdict — worktree-level park (prop from
// Terminal.tsx) or per-tab cold park, never portal-hosted tabs. Render and
// the watcher-sync effect must share this exact set so watcher lifecycle
// tracks the committed unmounts.
const parkedTerminalTabIds = useMemo(() => {
const parked = new Set<string>()
for (const terminalTab of terminalTabs) {
const assignment = assignments.get(terminalTab.id)
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
const hasActivityTerminalPortal =
findActivityTerminalPortal(activityTerminalPortals, {
worktreeId,
tabId: terminalTab.id
}) !== null
if (
(coldParkTerminalPanes || (!isVisible && coldParkedTerminalTabIds.has(terminalTab.id))) &&
!hasActivityTerminalPortal &&
// Why: the hidden-measuring startup probe needs mounted panes; gate
// here too so the reveal lands in the same render that starts it.
!shouldMeasureHiddenWorktree
) {
parked.add(terminalTab.id)
}
}
return parked
}, [
activityTerminalPortals,
assignments,
coldParkTerminalPanes,
coldParkedTerminalTabIds,
isWorktreeActive,
shouldMeasureHiddenWorktree,
terminalTabs,
worktreeId
])
// Why: runs in the same effect flush as the commit that parked/revealed the
// panes — watcher disposal therefore lands before any PTY data IPC can
// reach a freshly remounted pane, and watcher start lands after the parked
// pane's unmount capture.
useEffect(() => {
syncParkedTerminalTabWatchers({
worktreeId,
tabs: terminalTabs,
parkedTabIds: parkedTerminalTabIds
})
}, [parkedTerminalTabIds, terminalTabs, worktreeId])
useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId])
return parkedTerminalTabIds
}
+4
View File
@@ -67,6 +67,10 @@ declare global {
interface Window {
__paneManagers?: Map<string, PaneManager>
__onboardingFeatureSetupDeps?: OnboardingFeatureSetupDeps
__terminalParkingDebug?: {
parkDelayMs: number
parkedTabIds: () => string[]
}
}
}
+8 -97
View File
@@ -1,11 +1,13 @@
// Why: the parsing core moved to shared so main's terminal side-effect
// tracker can emit pr-link facts (terminal-side-effect-authority.md, slice 3).
// Re-exported here so renderer consumers keep their '@/lib' import path.
// normalizeGitHubLinkQuery stays renderer-side: its too-large guard is link-
// picker input policy, not parsing.
import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '../../../shared/github-links'
import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds'
const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i
export type RepoSlug = {
owner: string
repo: string
}
export * from '../../../shared/github-links'
export type GitHubLinkQuery = {
query: string
@@ -13,97 +15,6 @@ export type GitHubLinkQuery = {
tooLarge?: boolean
}
export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null {
if (!slug?.owner || !slug.repo) {
return null
}
return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}`
}
function matchGitHubItemPath(url: URL): RegExpExecArray | null {
return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, ''))
}
function parseGitHubItemNumber(value: string): number | null {
const parsed = Number.parseInt(value, 10)
return parsed > 0 ? parsed : null
}
/**
* Parses a GitHub issue/PR reference from plain input.
* Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs.
*/
export function parseGitHubIssueOrPRNumber(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
if (/^\d+$/.test(numeric)) {
return parseGitHubItemNumber(numeric)
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
return parseGitHubItemNumber(match[4])
}
/**
* Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns
* null for anything that isn't a recognizable GitHub-shaped issue or pull URL.
*/
export function parseGitHubIssueOrPRLink(input: string): {
slug: RepoSlug
number: number
type: 'issue' | 'pr'
} | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
const number = parseGitHubItemNumber(match[4])
if (number === null) {
return null
}
return {
slug: { owner: match[1], repo: match[2] },
type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
number
}
}
/**
* Normalizes link-picker input so both raw issue/PR numbers and full GitHub
* URLs resolve to a usable query + direct-number lookup.
@@ -20,7 +20,7 @@ import type {
WorktreeMeta,
WorkspaceKey
} from '../../../../shared/types'
import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector'
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
import type {
PendingWorktreeCreation,
WorktreeCreationPhase
+20 -483
View File
@@ -1,23 +1,27 @@
/**
* Shared agent detection utilities — used by both the main process (stats
* collection) and the renderer (activity indicators, unread badges).
* Compatibility barrel for shared terminal agent-title detection.
*
* Why shared: the main process needs the same OSC title extraction and agent
* status detection for stat tracking that the renderer uses for UI indicators.
* Duplicating this logic would risk drift between the two detection paths.
* Why shared: main and renderer both consume OSC titles for facts, stats, and
* UI state. Keep existing imports stable while the implementation stays split
* into focused modules that satisfy max-lines.
*/
import {
AGY_AGENT_NAME_RE,
DROID_AGENT_NAME_RE,
HERMES_AGENT_NAME_RE,
titleHasAgentName,
titleHasAnyLegacyAgentName
} from './agent-name-token-match'
import {
getPiCompatibleSyntheticAgentLabel,
getPiCompatibleSyntheticAgentStatus
} from './pi-compatible-synthetic-title'
export type { AgentStatus } from './agent-title-core'
export {
isClaudeManagementTitle,
isCursorNativeAgentTitle,
isGeminiTerminalTitle,
isPiTerminalTitle,
STRONG_IDLE_KEYWORDS_RE,
STRONG_WORKING_KEYWORDS_RE
} from './agent-title-core'
export { getAgentLabel, isClaudeAgent } from './agent-title-identity'
export {
clearWorkingIndicators,
createAgentStatusTracker,
detectAgentStatusFromTitle,
normalizeTerminalTitle
} from './agent-title-status'
// Re-export so existing `agent-detection` importers keep working.
export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match'
@@ -27,470 +31,3 @@ export {
MAX_OSC_TITLE_CHARS
} from './osc-title-extraction'
export { isShellProcess } from './shell-process-detection'
export type AgentStatus = 'working' | 'permission' | 'idle'
const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix)
const CLAUDE_MANAGEMENT_TITLE_RE =
/^\s*(?:"(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?"|'(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?'|(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?)\s+agents\s*$/i
const GEMINI_WORKING = '\u2726' // ✦
const GEMINI_SILENT_WORKING = '\u23F2' // ⏲
const GEMINI_IDLE = '\u25C7' // ◇
const GEMINI_PERMISSION = '\u270B' // ✋
// Why: idle keywords used inside `detectAgentStatusFromTitle` to map titles
// like "Codex done", "OpenCode ready", "Aider idle" to AgentStatus 'idle'.
// `as const` so consumers receive literal-union types.
const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const
// Why: working keywords used inside `detectAgentStatusFromTitle` to map
// titles like "Codex working", "Aider thinking", "OpenCode running" to
// AgentStatus 'working'. Shared with `clearWorkingIndicators` so both stay
// in lock-step when stripping working indicators from stale titles.
const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const
// Why: match STRONG_IDLE_KEYWORDS only when not adjacent to characters that
// would make the "keyword" part of a larger token. Plain `\b` alone is
// insufficient because `-` is a non-word character in JS regex, so `\bready\b`
// still matches inside "is-ready-cap" (a `\b` boundary falls between `-` and
// `r`).
//
// Lookarounds are intentionally ASYMMETRIC:
// - LEFT: reject `[\w./\\-]` so path fragments like `~/codex/ready`,
// Windows `C:\codex\ready`, and `codex.ready` cannot mint a strong idle
// signal by having the agent name sit earlier in the same path and the
// keyword land right after a path separator. Orca is a cross-platform
// Electron app, so Windows path separators must be handled too.
// - RIGHT: reject only `[\w\-]` so legitimate sentence-style titles like
// "Codex done." / "Aider idle." / "OpenCode ready!" still match — path
// separators after the keyword are not a false-positive vector in
// practice and blocking them would regress trailing-punctuation titles.
//
// Also rejects hyphenated compounds ("is-ready-cap", "re-done") and plain
// substring false positives ("already"/"redone"/"idleness").
export const STRONG_IDLE_KEYWORDS_RE = new RegExp(
`(?<![\\w./\\\\-])(${STRONG_IDLE_KEYWORDS.join('|')})(?![\\w\\-])`,
'i'
)
// Why: mirrors STRONG_IDLE_KEYWORDS_RE — plain substring matching on the
// working keywords caused the symmetric class of false positives, e.g.
// "reworking" ⊃ "working", "overthinking" ⊃ "thinking", "rerunning" ⊃
// "running", hyphenated compounds like "is-thinking-cap", AND cwd-path
// fragments like "~/codex/working" or "C:\codex\working". Uses the same
// asymmetric lookarounds as STRONG_IDLE_KEYWORDS_RE (path separators blocked
// on the left only so "Codex working." still matches). A false 'working'
// classification is worse than the idle one because it drives active-agent
// UI (spinners, counts), so word-char- and left-path-separator-aware
// matching is required here too.
export const STRONG_WORKING_KEYWORDS_RE = new RegExp(
`(?<![\\w./\\\\-])(${STRONG_WORKING_KEYWORDS.join('|')})(?![\\w\\-])`,
'i'
)
// Why: global-flag companion of STRONG_WORKING_KEYWORDS_RE used by
// clearWorkingIndicators to strip ALL occurrences in a single pass. Keeps
// clearing and detection in lock-step — both use identical [\w\-] lookarounds,
// so `clearWorkingIndicators` no longer strips keywords out of hyphenated
// compounds like "is-working-cap" that `detectAgentStatusFromTitle` would
// correctly refuse to classify as working.
const STRONG_WORKING_KEYWORDS_RE_GLOBAL = new RegExp(STRONG_WORKING_KEYWORDS_RE.source, 'gi')
const PI_IDLE_PREFIX = '\u03c0 - ' // π - (Pi titlebar extension idle format)
export function isGeminiTerminalTitle(title: string): boolean {
return (
title.includes(GEMINI_PERMISSION) ||
title.includes(GEMINI_WORKING) ||
title.includes(GEMINI_SILENT_WORKING) ||
title.includes(GEMINI_IDLE) ||
title.toLowerCase().includes('gemini')
)
}
export function isPiTerminalTitle(title: string): boolean {
return title.startsWith(PI_IDLE_PREFIX)
}
function isPiAgentTitle(title: string): boolean {
return (
isPiTerminalTitle(title) || (containsBrailleSpinner(title) && title.includes(PI_IDLE_PREFIX))
)
}
function containsBrailleSpinner(title: string): boolean {
for (const char of title) {
const codePoint = char.codePointAt(0)
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
return true
}
}
return false
}
function containsAgentName(title: string): boolean {
return (
titleHasAnyLegacyAgentName(title) ||
AGY_AGENT_NAME_RE.test(title) ||
DROID_AGENT_NAME_RE.test(title) ||
HERMES_AGENT_NAME_RE.test(title)
)
}
function containsAny(title: string, words: readonly string[]): boolean {
const lower = title.toLowerCase()
return words.some((word) => lower.includes(word))
}
/**
* Strip working-status indicators from a title so that
* `detectAgentStatusFromTitle` will no longer return 'working'.
* Used to clear stale titles when an agent exits without resetting its title.
*/
export function clearWorkingIndicators(title: string): string {
let cleaned = title
// Gemini working symbols
cleaned = cleaned.replace(GEMINI_WORKING, '')
cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '')
// Braille spinner characters (U+2800–U+28FF)
// eslint-disable-next-line no-control-regex -- intentional unicode range
cleaned = cleaned.replace(/[\u2800-\u28FF]/g, '')
// Claude Code ". " working prefix
if (cleaned.startsWith('. ')) {
cleaned = cleaned.slice(2)
}
// Strip working keywords that detectAgentStatusFromTitle would pick up
// when the title also contains an agent name.
if (containsAgentName(cleaned)) {
cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '')
}
// Collapse whitespace after removals
cleaned = cleaned.replace(/\s{2,}/g, ' ').trim()
return cleaned || title
}
/**
* Tracks agent status transitions from terminal title changes.
* Fires `onBecameIdle` when an agent transitions from working to idle/permission,
* like haunt's attention flag — the key trigger for unread notifications.
*/
export function createAgentStatusTracker(
onBecameIdle: (title: string) => void,
onBecameWorking?: () => void,
onAgentExited?: () => void
): {
handleTitle: (title: string) => void
/** Clear accumulated status so a stale working→idle transition cannot fire
* after the owning transport is torn down. */
reset: () => void
} {
let lastStatus: AgentStatus | null = null
return {
handleTitle(title: string): void {
const newStatus = detectAgentStatusFromTitle(title)
if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') {
onBecameIdle(title)
}
if (lastStatus !== 'working' && newStatus === 'working') {
onBecameWorking?.()
}
// Why: when the title reverts to a plain shell prompt (e.g., "bash", "zsh"),
// detectAgentStatusFromTitle returns null. If we were idle or in a permission
// prompt, this means the user exited the agent — clear session-tied state
// (like the prompt-cache countdown). We intentionally do NOT fire this when
// lastStatus is 'working', because active agents can briefly flash shell
// titles during internal operations without actually exiting.
if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) {
lastStatus = null
onAgentExited?.()
}
if (newStatus !== null) {
lastStatus = newStatus
}
},
reset(): void {
lastStatus = null
}
}
}
/**
* Normalize high-churn agent titles into stable display labels before storing
* them in app state. Gemini CLI can emit per-keystroke title updates, which
* otherwise causes broad rerenders and visible flashing.
*/
export function normalizeTerminalTitle(title: string): string {
if (!title) {
return title
}
if (isGeminiTerminalTitle(title)) {
const status = detectAgentStatusFromTitle(title)
if (status === 'permission') {
return `${GEMINI_PERMISSION} Gemini CLI`
}
if (status === 'working') {
return `${GEMINI_WORKING} Gemini CLI`
}
if (status === 'idle') {
return `${GEMINI_IDLE} Gemini CLI`
}
}
// Why: Pi's titlebar extension animates every 80ms with different braille
// frames. Collapsing those frames into one stable label avoids renderer
// churn while preserving the working/idle transition Orca keys off.
if (isPiAgentTitle(title)) {
const status = detectAgentStatusFromTitle(title)
if (status === 'working') {
return '\u280b Pi'
}
if (status === 'idle') {
return 'Pi'
}
}
return title
}
/**
* Returns true when the terminal title matches Claude Code's title conventions.
* Used to scope prompt-cache-timer behavior to Claude sessions only — other
* agents have different (or no) caching semantics.
*/
export function isClaudeAgent(title: string): boolean {
if (!title || isClaudeManagementTitle(title)) {
return false
}
const lower = title.toLowerCase()
// Why: Claude Code titles are prefixed with status indicators (✳, ". ", "* ",
// braille spinners) followed by the task description. The task text can
// legitimately mention other agents, so Claude-specific prefixes must win.
if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) {
return true
}
// Why: ". " (working) and "* " (idle) are Claude Code title conventions.
// Other supported agents do not use them, and rejecting titles that mention
// another agent in the task text caused false negatives for real Claude tabs.
if (title.startsWith('. ') || title.startsWith('* ')) {
return true
}
if (containsBrailleSpinner(title)) {
// Why: named non-Claude agents can carry braille spinners too; Claude-only
// prompt-cache paths must not fire for those explicit agent titles.
return !lower.includes('cursor') && !lower.includes('openclaude')
}
// Why: permission/action-required Claude titles can omit the usual prefixes.
// Token-match so cwd/worktree titles like "claude-scratch" do not become
// Claude tabs, while task text that merely mentions Claude still stays out.
const trimmedTitle = title.trimStart()
if (
trimmedTitle.toLowerCase().startsWith('claude') &&
titleHasAgentName(trimmedTitle, 'claude')
) {
return true
}
return false
}
export function isClaudeManagementTitle(title: string): boolean {
return CLAUDE_MANAGEMENT_TITLE_RE.test(title)
}
export function getAgentLabel(title: string): string | null {
if (isClaudeManagementTitle(title)) {
return null
}
// Why: Claude Code title text is often the task title. If that task mentions
// another CLI, the Claude-specific prefix is the identity signal, not the words.
if (
title.startsWith(`${CLAUDE_IDLE} `) ||
title === CLAUDE_IDLE ||
title.startsWith('. ') ||
title.startsWith('* ')
) {
return 'Claude Code'
}
if (isGeminiTerminalTitle(title)) {
return 'Gemini CLI'
}
// Why: Pi-compatible synthetic titles can carry braille spinners, which the
// generic agent-title heuristics would otherwise claim first.
const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title)
if (piCompatibleSyntheticAgentLabel) {
return piCompatibleSyntheticAgentLabel
}
// Why: Pi working titles include a braille spinner prefix, which would be
// mistaken for Claude Code if we checked `isClaudeAgent` first.
if (isPiAgentTitle(title)) {
return 'Pi'
}
// Why: Codex/OpenCode/Aider can also use braille spinner prefixes while
// working. Prefer explicit name matches before Claude's generic spinner
// heuristic so mixed-agent hovercards stay truthful. Token-match (not
// substring) so cwd/worktree titles like "opencode-blinker" don't mint a
// false agent identity.
if (titleHasAgentName(title, 'codex')) {
return 'Codex'
}
if (titleHasAgentName(title, 'openclaude')) {
return 'OpenClaude'
}
if (titleHasAgentName(title, 'copilot')) {
return 'GitHub Copilot'
}
if (titleHasAgentName(title, 'grok')) {
return 'Grok'
}
if (titleHasAgentName(title, 'devin')) {
return 'Devin'
}
if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) {
return 'Antigravity'
}
if (titleHasAgentName(title, 'opencode')) {
return 'OpenCode'
}
if (titleHasAgentName(title, 'mimo')) {
return 'MiMo Code'
}
if (titleHasAgentName(title, 'aider')) {
return 'Aider'
}
// Why: the cursor-agent native title is the literal string "Cursor Agent"
// (verified against the 2026.04.17 release) — Orca synthesizes the same
// label from hook events so the braille-spinner + agent-name path lights
// up working/permission/idle transitions in the renderer. Match before
// `isClaudeAgent` because Claude's generic braille heuristic would
// otherwise claim every "⠋ Cursor Agent" frame as Claude. Token-match so a
// cwd like "~/cursor-rules" can't masquerade as a Cursor agent.
if (titleHasAgentName(title, 'cursor')) {
return 'Cursor'
}
// Why: synthesized "⠋ Droid" working title needs to be matched before Claude's braille heuristic.
// Token matching avoids labeling ordinary Android terminal titles as Droid.
if (DROID_AGENT_NAME_RE.test(title)) {
return 'Droid'
}
// Why: synthesized "⠋ Hermes" working titles need to be matched before
// Claude's generic braille-spinner heuristic.
if (HERMES_AGENT_NAME_RE.test(title)) {
return 'Hermes'
}
if (isClaudeAgent(title)) {
return 'Claude Code'
}
return null
}
// Why: cursor-agent's native OSC title is the literal string "Cursor Agent"
// across the entire turn — it carries zero working/idle information. Orca
// synthesizes its own titles ("⠋ Cursor Agent" for working, "Cursor -
// action required" for permission) from cursor's hook events; the bare
// native title must be a no-op so cursor's per-turn re-emissions cannot
// stomp the synthesized state back to idle.
const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent'
export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
if (!title) {
return null
}
if (isClaudeManagementTitle(title)) {
return null
}
// Why: "Cursor Agent" exactly (case-insensitive, no prefix/suffix) is cursor's
// native title. Anything with additional tokens ("⠋ Cursor Agent", "Cursor -
// action required") is either an Orca-synthesized working/permission title
// or a tighter match worth classifying.
if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) {
return null
}
// Gemini CLI symbols are the most specific and should take precedence.
if (title.includes(GEMINI_PERMISSION)) {
return 'permission'
}
if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) {
return 'working'
}
if (title.includes(GEMINI_IDLE)) {
return 'idle'
}
// Why: resolve synthetic Pi/OMP permission/idle labels before the broader
// Pi and braille-spinner checks below.
const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title)
if (piCompatibleSyntheticAgentStatus) {
return piCompatibleSyntheticAgentStatus
}
// Claude Code uses ✳ prefix for idle — must check before braille/agent-name
// because the title text is the task description, not "Claude Code".
if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) {
return 'idle'
}
if (isPiTerminalTitle(title)) {
return 'idle'
}
if (containsBrailleSpinner(title)) {
return 'working'
}
const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title)
const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title)
const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title)
const hasLegacyAgentName = titleHasAnyLegacyAgentName(title)
if (hasLegacyAgentName || hasDroidAgentName || hasHermesAgentName || hasAgyAgentName) {
if (containsAny(title, ['action required', 'permission', 'waiting'])) {
return 'permission'
}
// Why: hyphen/word-char-aware boundary match (not plain substring, and
// stricter than `\b` — which treats `-` as a boundary) so titles like
// "~/codex already built" do not classify as idle via the substring
// "already" ⊃ "ready". See STRONG_IDLE_KEYWORDS_RE comment.
if (STRONG_IDLE_KEYWORDS_RE.test(title)) {
return 'idle'
}
// Why: hyphen/word-char-aware boundary match (not plain substring, and
// stricter than `\b`) so titles like "~/codex reworking diff" or
// "is-thinking-cap" do not classify as working via the substrings
// "reworking" ⊃ "working" or the `-`-adjacent "thinking" in
// "is-thinking-cap". Mirrors STRONG_IDLE_KEYWORDS_RE for symmetry; a
// false 'working' is worse than a false 'idle' because it drives
// active-agent UI (spinners, counts).
if (STRONG_WORKING_KEYWORDS_RE.test(title)) {
return 'working'
}
// Claude Code title prefixes: ". " = working, "* " = idle
if (title.startsWith('. ')) {
return 'working'
}
if (title.startsWith('* ')) {
return 'idle'
}
// Why: Factory Droid can publish native titles like "Factory Droid needs
// input" while an Execute tool is still sleeping. Droid's hook events are
// authoritative; don't turn a name-only native title into a completion.
if (hasDroidAgentName && !hasLegacyAgentName) {
return null
}
return 'idle'
}
return null
}
+104
View File
@@ -0,0 +1,104 @@
import {
AGY_AGENT_NAME_RE,
DROID_AGENT_NAME_RE,
HERMES_AGENT_NAME_RE,
titleHasAgentName,
titleHasAnyLegacyAgentName
} from './agent-name-token-match'
export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName }
export type AgentStatus = 'working' | 'permission' | 'idle'
export const CLAUDE_IDLE = '\u2733' // ✳
const CLAUDE_COMMAND_RE = String.raw`(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?`
export const CLAUDE_MANAGEMENT_TITLE_RE = new RegExp(
String.raw`^\s*(?:"${CLAUDE_COMMAND_RE}"|'${CLAUDE_COMMAND_RE}'|${CLAUDE_COMMAND_RE})\s+agents\s*$`,
'i'
)
export const GEMINI_WORKING = '\u2726' // ✦
export const GEMINI_SILENT_WORKING = '\u23f2' // ⏲
export const GEMINI_IDLE = '\u25c7' // ◇
export const GEMINI_PERMISSION = '\u270b' // ✋
const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const
const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const
// Why: plain `\b` matches inside hyphenated tokens and cwd paths such as
// "~/codex/ready"; the left side also blocks path separators for Windows/Unix.
export const STRONG_IDLE_KEYWORDS_RE = new RegExp(
`(?<![\\w./\\\\-])(${STRONG_IDLE_KEYWORDS.join('|')})(?![\\w\\-])`,
'i'
)
// Why: mirrors the idle matcher so titles like "reworking" or
// "is-thinking-cap" do not drive false active-agent UI.
export const STRONG_WORKING_KEYWORDS_RE = new RegExp(
`(?<![\\w./\\\\-])(${STRONG_WORKING_KEYWORDS.join('|')})(?![\\w\\-])`,
'i'
)
export const STRONG_WORKING_KEYWORDS_RE_GLOBAL = new RegExp(STRONG_WORKING_KEYWORDS_RE.source, 'gi')
export const PI_IDLE_PREFIX = '\u03c0 - ' // π -
export const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent'
// eslint-disable-next-line no-control-regex -- intentional unicode range
export const BRAILLE_SPINNER_RE = /[\u2800-\u28ff]/g
export function isGeminiTerminalTitle(title: string): boolean {
return (
title.includes(GEMINI_PERMISSION) ||
title.includes(GEMINI_WORKING) ||
title.includes(GEMINI_SILENT_WORKING) ||
title.includes(GEMINI_IDLE) ||
title.toLowerCase().includes('gemini')
)
}
export function isPiTerminalTitle(title: string): boolean {
return title.startsWith(PI_IDLE_PREFIX)
}
export function isPiAgentTitle(title: string): boolean {
return (
isPiTerminalTitle(title) || (containsBrailleSpinner(title) && title.includes(PI_IDLE_PREFIX))
)
}
export function containsBrailleSpinner(title: string): boolean {
for (const char of title) {
const codePoint = char.codePointAt(0)
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
return true
}
}
return false
}
export function containsLegacyAgentName(title: string): boolean {
return titleHasAnyLegacyAgentName(title)
}
export function containsAgentName(title: string): boolean {
return (
containsLegacyAgentName(title) ||
AGY_AGENT_NAME_RE.test(title) ||
DROID_AGENT_NAME_RE.test(title) ||
HERMES_AGENT_NAME_RE.test(title)
)
}
export function containsAny(title: string, words: readonly string[]): boolean {
const lower = title.toLowerCase()
return words.some((word) => lower.includes(word))
}
export function isClaudeManagementTitle(title: string): boolean {
return CLAUDE_MANAGEMENT_TITLE_RE.test(title)
}
export function isCursorNativeAgentTitle(title: string): boolean {
return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER
}
+111
View File
@@ -0,0 +1,111 @@
import {
AGY_AGENT_NAME_RE,
CLAUDE_IDLE,
DROID_AGENT_NAME_RE,
HERMES_AGENT_NAME_RE,
containsBrailleSpinner,
isClaudeManagementTitle,
isGeminiTerminalTitle,
isPiAgentTitle,
titleHasAgentName
} from './agent-title-core'
import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title'
/**
* Returns true when the terminal title matches Claude Code's title conventions.
* Used to scope prompt-cache-timer behavior to Claude sessions only.
*/
export function isClaudeAgent(title: string): boolean {
if (!title || isClaudeManagementTitle(title)) {
return false
}
const lower = title.toLowerCase()
// Why: Claude title prefixes are stronger than task text, which can mention
// other agents without changing the owning CLI.
if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) {
return true
}
if (title.startsWith('. ') || title.startsWith('* ')) {
return true
}
if (containsBrailleSpinner(title)) {
return !lower.includes('cursor') && !lower.includes('openclaude')
}
const trimmedTitle = title.trimStart()
return (
trimmedTitle.toLowerCase().startsWith('claude') && titleHasAgentName(trimmedTitle, 'claude')
)
}
export function getAgentLabel(title: string): string | null {
if (isClaudeManagementTitle(title)) {
return null
}
// Why: Claude task titles can mention another CLI; the prefix is the identity
// signal, not arbitrary task text.
if (
title.startsWith(`${CLAUDE_IDLE} `) ||
title === CLAUDE_IDLE ||
title.startsWith('. ') ||
title.startsWith('* ')
) {
return 'Claude Code'
}
if (isGeminiTerminalTitle(title)) {
return 'Gemini CLI'
}
// Why: Pi-compatible synthetic titles can carry braille spinners, which the
// generic agent-title heuristics would otherwise claim first.
const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title)
if (piCompatibleSyntheticAgentLabel) {
return piCompatibleSyntheticAgentLabel
}
if (isPiAgentTitle(title)) {
return 'Pi'
}
if (titleHasAgentName(title, 'codex')) {
return 'Codex'
}
if (titleHasAgentName(title, 'openclaude')) {
return 'OpenClaude'
}
if (titleHasAgentName(title, 'copilot')) {
return 'GitHub Copilot'
}
if (titleHasAgentName(title, 'grok')) {
return 'Grok'
}
if (titleHasAgentName(title, 'devin')) {
return 'Devin'
}
if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) {
return 'Antigravity'
}
if (titleHasAgentName(title, 'opencode')) {
return 'OpenCode'
}
if (titleHasAgentName(title, 'mimo')) {
return 'MiMo Code'
}
if (titleHasAgentName(title, 'aider')) {
return 'Aider'
}
// Why: match explicit names before Claude's generic braille heuristic.
if (titleHasAgentName(title, 'cursor')) {
return 'Cursor'
}
if (DROID_AGENT_NAME_RE.test(title)) {
return 'Droid'
}
if (HERMES_AGENT_NAME_RE.test(title)) {
return 'Hermes'
}
if (isClaudeAgent(title)) {
return 'Claude Code'
}
return null
}
+194
View File
@@ -0,0 +1,194 @@
import {
AGY_AGENT_NAME_RE,
BRAILLE_SPINNER_RE,
CLAUDE_IDLE,
CURSOR_NATIVE_TITLE_LOWER,
DROID_AGENT_NAME_RE,
GEMINI_IDLE,
GEMINI_PERMISSION,
GEMINI_SILENT_WORKING,
GEMINI_WORKING,
HERMES_AGENT_NAME_RE,
STRONG_IDLE_KEYWORDS_RE,
STRONG_WORKING_KEYWORDS_RE,
STRONG_WORKING_KEYWORDS_RE_GLOBAL,
containsAgentName,
containsAny,
containsBrailleSpinner,
containsLegacyAgentName,
isClaudeManagementTitle,
isGeminiTerminalTitle,
isPiAgentTitle,
isPiTerminalTitle
} from './agent-title-core'
import type { AgentStatus } from './agent-title-core'
import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title'
/**
* Strip working-status indicators so stale exit titles stop reporting working.
*/
export function clearWorkingIndicators(title: string): string {
let cleaned = title
cleaned = cleaned.replace(GEMINI_WORKING, '')
cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '')
cleaned = cleaned.replace(BRAILLE_SPINNER_RE, '')
if (cleaned.startsWith('. ')) {
cleaned = cleaned.slice(2)
}
if (containsAgentName(cleaned)) {
cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '')
}
cleaned = cleaned.replace(/\s{2,}/g, ' ').trim()
return cleaned || title
}
/**
* Tracks agent status transitions from terminal title changes.
*/
export function createAgentStatusTracker(
onBecameIdle: (title: string) => void,
onBecameWorking?: () => void,
onAgentExited?: () => void,
initialTitle?: string
): {
handleTitle: (title: string) => void
seedTitle: (title: string) => void
reset: () => void
} {
// Why: trackers restored mid-session need a last-known status without firing
// callbacks, or a hidden working agent can miss its later idle transition.
let lastStatus: AgentStatus | null =
initialTitle !== undefined ? detectAgentStatusFromTitle(initialTitle) : null
return {
handleTitle(title: string): void {
const newStatus = detectAgentStatusFromTitle(title)
if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') {
onBecameIdle(title)
}
if (lastStatus !== 'working' && newStatus === 'working') {
onBecameWorking?.()
}
// Why: reverting to a plain shell prompt after idle/permission means the
// agent exited; while working it can just be a transient internal title.
if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) {
lastStatus = null
onAgentExited?.()
}
if (newStatus !== null) {
lastStatus = newStatus
}
},
seedTitle(title: string): void {
lastStatus = detectAgentStatusFromTitle(title)
},
reset(): void {
lastStatus = null
}
}
}
/**
* Normalize high-churn agent titles into stable display labels before storage.
*/
export function normalizeTerminalTitle(title: string): string {
if (!title) {
return title
}
if (isGeminiTerminalTitle(title)) {
const status = detectAgentStatusFromTitle(title)
if (status === 'permission') {
return `${GEMINI_PERMISSION} Gemini CLI`
}
if (status === 'working') {
return `${GEMINI_WORKING} Gemini CLI`
}
if (status === 'idle') {
return `${GEMINI_IDLE} Gemini CLI`
}
}
// Why: Pi animates every 80ms; collapse frames while preserving status.
if (isPiAgentTitle(title)) {
const status = detectAgentStatusFromTitle(title)
if (status === 'working') {
return '\u280b Pi'
}
if (status === 'idle') {
return 'Pi'
}
}
return title
}
export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
if (!title || isClaudeManagementTitle(title)) {
return null
}
if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) {
return null
}
if (title.includes(GEMINI_PERMISSION)) {
return 'permission'
}
if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) {
return 'working'
}
if (title.includes(GEMINI_IDLE)) {
return 'idle'
}
// Why: resolve synthetic Pi/OMP permission/idle labels before the broader
// Pi and braille-spinner checks below.
const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title)
if (piCompatibleSyntheticAgentStatus) {
return piCompatibleSyntheticAgentStatus
}
if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) {
return 'idle'
}
if (isPiTerminalTitle(title)) {
return 'idle'
}
if (containsBrailleSpinner(title)) {
return 'working'
}
const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title)
const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title)
const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title)
const hasLegacyAgentName = containsLegacyAgentName(title)
if (!hasLegacyAgentName && !hasDroidAgentName && !hasHermesAgentName && !hasAgyAgentName) {
return null
}
if (containsAny(title, ['action required', 'permission', 'waiting'])) {
return 'permission'
}
// Why: boundary-aware regexes avoid cwd/path and substring false positives.
if (STRONG_IDLE_KEYWORDS_RE.test(title)) {
return 'idle'
}
if (STRONG_WORKING_KEYWORDS_RE.test(title)) {
return 'working'
}
if (title.startsWith('. ')) {
return 'working'
}
if (title.startsWith('* ')) {
return 'idle'
}
// Why: Droid hook events are authoritative; native name-only titles should
// not turn a still-sleeping execute tool into completion.
if (hasDroidAgentName && !hasLegacyAgentName) {
return null
}
return 'idle'
}
+10
View File
@@ -305,6 +305,16 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
claudeManagedAccounts: [],
activeClaudeManagedAccountId: null,
terminalScopeHistoryByWorktree: true,
// Why these four differ from PR #7214 (their source): this build has no
// main-process side-effect emitter, so the parked watcher MUST stay in
// byte mode (authority/gate/query-authority false) or parked panes would
// silently lose bell/title/completion notifications. Parking itself ships
// default-OFF (opt-in) until the memory win is confirmed in a soak.
// When #7214 lands its emitter, reconcile by taking its `true` values.
terminalHiddenViewParking: false,
terminalMainSideEffectAuthority: false,
terminalHiddenDeliveryGate: false,
terminalModelQueryAuthority: false,
defaultTuiAgent: null,
disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS],
claudeAgentTeamsDefaultDisabledMigrated: true,
+12 -1
View File
@@ -3,23 +3,34 @@ export type E2EConfig = {
headless: boolean
exposeStore: boolean
userDataDir: string | null
/** Test-only override (ORCA_E2E_TERMINAL_PARKING_DELAY_MS) shrinking the
* terminal hidden-view parking delays. null means use production timing. */
terminalParkingDelayMs: number | null
}
type E2EConfigInput = {
headless?: boolean
exposeStore?: boolean
userDataDir?: string | null
terminalParkingDelayMs?: number | null
}
export function createE2EConfig(input: E2EConfigInput): E2EConfig {
const userDataDir = input.userDataDir?.trim() || null
const headless = Boolean(input.headless)
const exposeStore = Boolean(input.exposeStore)
const terminalParkingDelayMs =
typeof input.terminalParkingDelayMs === 'number' &&
Number.isFinite(input.terminalParkingDelayMs) &&
input.terminalParkingDelayMs > 0
? input.terminalParkingDelayMs
: null
return {
enabled: headless || exposeStore || userDataDir !== null,
headless,
exposeStore,
userDataDir
userDataDir,
terminalParkingDelayMs
}
}
+100
View File
@@ -0,0 +1,100 @@
// Why shared: main's terminal side-effect tracker emits pr-link facts
// (terminal-side-effect-authority.md, slice 3) and needs the same GitHub URL
// parsing core the renderer link picker uses.
const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i
export type RepoSlug = {
owner: string
repo: string
}
export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null {
if (!slug?.owner || !slug.repo) {
return null
}
return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}`
}
function matchGitHubItemPath(url: URL): RegExpExecArray | null {
return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, ''))
}
function parseGitHubItemNumber(value: string): number | null {
const parsed = Number.parseInt(value, 10)
return parsed > 0 ? parsed : null
}
/**
* Parses a GitHub issue/PR reference from plain input.
* Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs.
*/
export function parseGitHubIssueOrPRNumber(input: string): number | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
if (/^\d+$/.test(numeric)) {
return parseGitHubItemNumber(numeric)
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
return parseGitHubItemNumber(match[4])
}
/**
* Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns
* null for anything that isn't a recognizable GitHub-shaped issue or pull URL.
*/
export function parseGitHubIssueOrPRLink(input: string): {
slug: RepoSlug
number: number
type: 'issue' | 'pr'
} | null {
const trimmed = input.trim()
if (!trimmed) {
return null
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return null
}
const match = matchGitHubItemPath(url)
if (!match) {
return null
}
const number = parseGitHubItemNumber(match[4])
if (number === null) {
return null
}
return {
slug: { owner: match[1], repo: match[2] },
type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
number
}
}
@@ -1,3 +1,12 @@
/**
* Chunk-boundary-safe GitHub PR URL scan over PTY output.
*
* Why shared: terminal-side-effect-authority.md (slice 3) makes main emit
* `pr-link` facts from its per-PTY tracker for local/SSH PTYs, while the
* renderer keeps byte-scanning for remote-runtime PTYs and the kill-switch-off
* path. Both paths must share the carry/dedupe semantics or links split across
* chunks would resolve differently per authority mode.
*/
import type { RepoSlug } from './github-links'
import { parseGitHubIssueOrPRLink } from './github-links'
+56
View File
@@ -0,0 +1,56 @@
/**
* Derived terminal side-effect facts carried on the `pty:sideEffect` channel
* (main → renderer). Events are facts, not decisions: main parses every
* local-daemon/SSH PTY byte exactly once and emits what it observed; the
* renderer store handler owns notification/unread policy.
* See docs/reference/terminal-side-effect-authority.md.
*/
import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector'
/** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not
* observed bytes. Renderer policy clears title/cache state from them but
* must not schedule task-complete notifications or unread attention — a
* merely-paused agent (>3s silent mid-task) is not a completion. */
export type TerminalSideEffectFact =
| { kind: 'title'; normalizedTitle: string; rawTitle: string; staleWorkingTitleClear?: boolean }
| { kind: 'bell' }
| { kind: 'agent-working' }
| { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean }
| { kind: 'agent-exited' }
/** OSC 133;D — foreground shell command exited (exit code best-effort). */
| { kind: 'command-finished'; exitCode: number | null }
/** Carries the parsed link so the renderer store consumer never re-parses
* the URL (parse drift would break the per-PTY dedupe contract). */
| { kind: 'pr-link'; link: TerminalGitHubPRLink }
/** Command Code output scrape (that CLI lacks hooks). Working seeds the
* agent-status row immediately; done is a hint the renderer settle-checks
* against its live status row before completing the turn. */
| { kind: 'command-code-working'; prompt: string }
| { kind: 'command-code-done'; prompt: string }
/** DECSET 2031 color-scheme subscribe observed in the byte stream. Emitted
* so hidden-delivery-gated views (whose bytes never arrive) can still send
* the theme reply — the reply stays renderer-side because query authority
* belongs to the view (model/view contract invariant 6). */
| { kind: '2031-subscribe' }
export type TerminalSideEffectBatch = {
ptyId: string
/** PTY output byte sequence at emission. Replay batches carry the sequence
* their title state was current at, so the handler can drop a replay title
* older than the last live title fact it applied. */
seq: number
/** Facts from one chunk, in byte order: titles in sequence, then bell.
* Command Code scrape facts trail the chunk's parser facts — their policy
* (status-row seeding) never interacts with title/bell ordering. */
facts: TerminalSideEffectFact[]
/** True for (re)attach snapshots. Replay batches restore title state only —
* attention facts (bell, agent transitions) never replay. */
replay?: boolean
/** Main-known attribution from runtime leaf/PTY records (same resolution as
* agent-status events). Absent when main has no binding for the PTY yet. */
worktreeId?: string
tabId?: string
paneKey?: string
connectionId?: string | null
}
+22
View File
@@ -2706,6 +2706,28 @@ export type GlobalSettings = {
* does not surface commands from other worktrees. Defaults to true.
* Disable to revert to shared global shell history. */
terminalScopeHistoryByWorktree: boolean
/** Kill switch for hidden terminal view parking — unmounting long-hidden
* terminal panes while a pane-less watcher keeps PTY side effects alive.
* Defaults to true; `false` disables parking entirely.
* See docs/reference/terminal-hidden-view-parking.md. */
terminalHiddenViewParking?: boolean
/** Kill switch for main-process terminal side-effect authority: when true
* (default), local-daemon/SSH PTY title/bell/agent facts are consumed from
* the `pty:sideEffect` channel and renderer byte parsers stay unregistered
* for those PTYs; `false` restores renderer byte parsing.
* See docs/reference/terminal-side-effect-authority.md. */
terminalMainSideEffectAuthority?: boolean
/** Kill switch for main's hidden-delivery gate (Phase 4): when true
* (default) AND terminalMainSideEffectAuthority is on, main drops PTY byte
* delivery to hidden renderer views after model ingestion; reveal restores
* from the model snapshot. `false` restores hidden byte delivery. */
terminalHiddenDeliveryGate?: boolean
/** Kill switch for the main model query responder (Phase 5): when true
* (default) AND both Phase-4 gate switches are on, main answers terminal
* queries (DA1/CPR/DECRPM, …) embedded in hidden-dropped chunks from the
* runtime emulator. `false` silences the responder without changing drops.
* See docs/reference/terminal-query-authority.md. */
terminalModelQueryAuthority?: boolean
/** Which agent to pre-select in the new-workspace composer.
* - null: auto (first detected agent)
* - 'blank': blank terminal (no agent launched)
+35 -35
View File
@@ -51,6 +51,14 @@ type OrcaTestFixtures = {
// Why: most E2E specs need a ready project before assertions start. Golden
// first-run specs opt out so they can prove the zero-project onboarding path.
seedTestRepo: boolean
// Why: spec-scoped launch env. Mutating process.env at spec module scope
// leaks into other specs when a worker reloads files without replaying the
// first spec's afterAll; per-test launch env cannot leak.
orcaAppExtraEnv: Record<string, string>
// Why: spec-scoped Chromium switches (e.g. --enable-precise-memory-info for
// memory benchmarks). Prepended before the main entry so Electron forwards
// them to Chromium without affecting other specs' launches.
orcaAppExtraArgs: string[]
// Why: a few IPC repro specs need to launch the Electron app with a scoped
// PATH/token environment. Keep this fixture-owned so tests never mutate the
// developer's shell or already-running Orca instance.
@@ -203,7 +211,11 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
],
// Test-scoped: one Electron app per test
electronApp: async ({ dismissOnboarding, launchEnv }, provideFixture, testInfo) => {
electronApp: async (
{ dismissOnboarding, launchEnv, orcaAppExtraEnv, orcaAppExtraArgs },
provideFixture,
testInfo
) => {
const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js')
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-userdata-'))
@@ -240,7 +252,7 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
mkdirSync(recordVideoDir, { recursive: true })
}
const app = await electron.launch({
args: getOrcaElectronLaunchArgs(mainPath, headful),
args: [...orcaAppExtraArgs, ...getOrcaElectronLaunchArgs(mainPath, headful)],
...(slowMo > 0 ? { slowMo } : {}),
...(recordVideoDir ? { recordVideo: { dir: recordVideoDir } } : {}),
// Why: keep NODE_ENV=development so window.__store is exposed and
@@ -265,7 +277,8 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
!cleanEnv.ORCA_RELAY_PATH
? { ORCA_RELAY_PATH: path.join(process.cwd(), 'out', 'relay') }
: {}),
...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }),
...orcaAppExtraEnv
}
})
forwardElectronProcessLogs(app, testInfo)
@@ -281,6 +294,8 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
dismissOnboarding: [true, { option: true }],
seedTestRepo: [true, { option: true }],
launchEnv: [{}, { option: true }],
orcaAppExtraEnv: [{}, { option: true }],
orcaAppExtraArgs: [[], { option: true }],
// Test-scoped: grab the first BrowserWindow, add the test repo, and wait
// until the session is fully ready with a worktree active.
@@ -314,38 +329,23 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
await window.api.repos.add({ path: repoPath })
}, repoPath)
// Fetch repos in the renderer store so it picks up the new repo, then opt
// this disposable repo into showing external worktrees.
// Why: repos.add() fires a repos:changed echo that triggers a *concurrent*
// fetchRepos() in the renderer; the store's generation guard can then drop
// this awaited fetch's result, leaving `repos` briefly stale. Poll the
// public fetch path until the repo lands instead of asserting on the first
// tick (mirrors the seeded-worktree poll below). updateRepo is idempotent,
// so running it once the repo appears is safe across poll ticks.
await playwrightExpect
.poll(
() =>
page.evaluate(async (repoPath) => {
const store = window.__store
if (!store) {
return false
}
await store.getState().fetchRepos()
const repo = store.getState().repos.find((candidate) => candidate.path === repoPath)
if (!repo) {
return false
}
// Why: the fixture deliberately creates external Git worktrees. New
// repos hide those by default after the visibility rollout.
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
return true
}, repoPath),
{
timeout: 30_000,
message: `Expected e2e repo to be loaded: ${repoPath}`
}
)
.toBe(true)
// Fetch repos in the renderer store so it picks up the new repo
await page.evaluate(async (repoPath) => {
const store = window.__store
if (!store) {
return
}
await store.getState().fetchRepos()
const repo = store.getState().repos.find((candidate) => candidate.path === repoPath)
if (!repo) {
throw new Error(`Expected e2e repo to be loaded: ${repoPath}`)
}
// Why: the fixture deliberately creates external Git worktrees. New
// repos hide those by default after the visibility rollout, so opt this
// disposable repo into showing them before specs assert on worktree state.
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
}, repoPath)
// Wait for the repo to appear and fetch its worktrees
await page.evaluate(async () => {
@@ -0,0 +1,570 @@
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { randomUUID } from 'node:crypto'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getActiveTabId,
getWorktreeTabs,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActiveTerminalManager,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
// Why: the parking wiring registers this handle (dev/exposeStore builds only)
// so tests can detect that hidden-view parking is compiled in and which delay
// override the app actually applied.
type ParkingDebugWindow = Window & {
__terminalParkingDebug?: {
parkDelayMs?: number
}
}
// Why: production cold-park hysteresis is 30s with a multi-minute hot-retain
// window. The fast-park override must be scoped to THIS spec's app launches —
// mutating process.env at module scope leaked into later specs when a worker
// reloaded files without replaying this file's afterAll.
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
test.use({
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
})
const PARKED_FRAME_SCRIPT_DELAY_MS = 750
const PARKED_FRAME_COUNT = 25
function parkedTuiFrame(runId: string, frame: number): string {
const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}`
const rows = [
'╭────────────────────────────────────────────────────────────────────╮',
`│ Parked view restore Frame ${String(frame).padStart(3, '0')} ${frame % 2 === 0 ? '🟢' : '🟡'} ${progress} │`,
'├──────────────┬──────────────────────┬──────────────────────────────┤',
`│ model │ codex/opencode │ ${runId.slice(0, 28).padEnd(28)} │`,
`│ status │ ${frame % 2 === 0 ? 'thinking' : 'streaming'} │ input ${'#'.repeat((frame % 18) + 1).padEnd(22)} │`,
`│ diff │ +${String(frame * 3).padEnd(19)} │ -${String(frame).padEnd(27)} │`,
'╰──────────────┴──────────────────────┴──────────────────────────────╯',
`PARKED_RESTORE_FINAL_${runId}_${frame}`
]
return [
'\x1b[?2026h',
'\x1b[?1049h',
'\x1b[2J\x1b[H',
'\x1b[?25l',
rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'),
'\x1b[10;18H\x1b[?25h',
'\x1b[?2026l'
].join('')
}
function writeParkedFrameScript(scriptPath: string, runId: string): void {
const frames = Array.from({ length: PARKED_FRAME_COUNT }, (_, frame) =>
parkedTuiFrame(runId, frame)
)
mkdirSync(path.dirname(scriptPath), { recursive: true })
writeFileSync(
scriptPath,
`setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${PARKED_FRAME_SCRIPT_DELAY_MS})\n`
)
}
// Deterministic static alt-screen frame for the park/reveal cycle test: rich
// styling (box drawing, SGR colors, wide glyphs) that exercises the snapshot
// restore, painted once and held so the on-screen content is stable across
// cycles. No spinner/progress churn — the frame must be byte-identical every
// reveal so drift is detectable.
function cycleReferenceFrame(runId: string): string {
const rows = [
'╭──────────────────────────────────────────────────────────╮',
`│ Park/reveal cycle reference ${runId.slice(0, 8)} 🟢 你好世界 터미널 │`,
'├───────────────┬──────────────────────────────────────────┤',
`│ model │ \x1b[1mcodex/opencode\x1b[22m stream +142 -37 │`,
`│ status │ \x1b[38;5;204mrunning\x1b[0m\x1b[2;36m diff --git a/pty.ts esc↩ │`,
'╰───────────────┴──────────────────────────────────────────╯',
`CYCLE_REFERENCE_${runId}`
]
return [
'\x1b[?1049h',
'\x1b[2J\x1b[H',
'\x1b[?25l',
rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'),
'\x1b[?25h'
].join('')
}
function writeCycleReferenceScript(scriptPath: string, runId: string): void {
mkdirSync(path.dirname(scriptPath), { recursive: true })
// Paint the frame once, then hold the process open so the alt-screen TUI
// stays on screen (and the parkable PTY session stays alive) across cycles.
writeFileSync(
scriptPath,
`process.stdout.write(${JSON.stringify(cycleReferenceFrame(runId))}); setInterval(() => {}, 1000)\n`
)
}
// Why: serialize() re-emits the buffer with cursor-restore trailer sequences
// (ESC[…H, ESC[?25h) and the exact CSI form can differ run-to-run without any
// visible change. Compare the CONTENT rows, not the trailer — strip trailing
// control sequences and normalize whitespace-only tail lines.
function terminalContentRows(serialized: string): string[] {
// eslint-disable-next-line no-control-regex
const stripped = serialized.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
return stripped
.split(/\r?\n/)
.map((line) => line.replace(/\s+$/, ''))
.filter((line) => line.length > 0)
}
async function readParkingWiring(
page: Page
): Promise<{ present: boolean; parkDelayMs: number | null }> {
return page.evaluate(() => {
const debug = (window as ParkingDebugWindow).__terminalParkingDebug
return { present: debug !== undefined, parkDelayMs: debug?.parkDelayMs ?? null }
})
}
// Why: the spec lands ahead of the feature wiring. Skip (rather than fail)
// when the app under test does not expose the parking debug handle so this
// file is safe to merge in any order with the wiring branch.
async function skipUnlessParkingWired(page: Page): Promise<void> {
const deadline = Date.now() + 2_000
let wiring = await readParkingWiring(page)
while (!wiring.present && Date.now() < deadline) {
await page.waitForTimeout(250)
wiring = await readParkingWiring(page)
}
test.skip(
!wiring.present,
'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)'
)
}
// Why: this standalone extraction ships terminalHiddenViewParking default-OFF
// (opt-in until the memory win is soak-confirmed); PR #7214 later flips the
// default to true, making this enable a no-op. Same mutation path as the
// kill-switch test in terminal-parked-memory.spec.ts.
async function enableTerminalParking(page: Page): Promise<void> {
await page.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('parking spec: window.__store is unavailable')
}
await store.getState().updateSettings({ terminalHiddenViewParking: true })
})
}
type TerminalTabViewState = {
hasManager: boolean
paneCount: number
}
async function readTerminalTabViewState(page: Page, tabId: string): Promise<TerminalTabViewState> {
return page.evaluate((tabId) => {
const manager = window.__paneManagers?.get(tabId)
return {
hasManager: manager !== undefined,
paneCount: manager?.getPanes?.().length ?? 0
}
}, tabId)
}
// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so a
// missing manager is the observable signal that the tab's xterm was parked.
async function waitForTabParked(page: Page, tabId: string): Promise<number> {
const parkWaitStartedAt = Date.now()
await expect
.poll(async () => (await readTerminalTabViewState(page, tabId)).hasManager, {
timeout: Math.max(20_000, PARKING_DELAY_MS * 10),
message: `terminal tab ${tabId} did not park (pane manager still mounted)`
})
.toBe(false)
return Date.now() - parkWaitStartedAt
}
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
await page.evaluate((targetTabId) => {
const store = window.__store
if (!store) {
throw new Error('activateTerminalTab: window.__store is unavailable')
}
const state = store.getState()
state.setActiveTabType('terminal')
state.setActiveTab(targetTabId)
}, tabId)
await expect
.poll(() => getActiveTabId(page), {
timeout: 5_000,
message: `terminal tab ${tabId} did not become active`
})
.toBe(tabId)
}
async function createActiveTerminalTab(page: Page, worktreeId: string): Promise<string> {
const tabId = await page.evaluate((worktreeId) => {
const store = window.__store
if (!store) {
throw new Error('createActiveTerminalTab: window.__store is unavailable')
}
const state = store.getState()
const tab = state.createTab(worktreeId, undefined, undefined, { activate: true })
state.setActiveTab(tab.id)
state.setActiveTabType('terminal')
return tab.id
}, worktreeId)
await expect
.poll(() => getActiveTabId(page), {
timeout: 5_000,
message: 'newly created terminal tab did not become active'
})
.toBe(tabId)
await waitForActiveTerminalManager(page, 30_000)
await waitForPaneIdentitySnapshot(page, 1)
return tabId
}
async function getUnreadTerminalTabIds(page: Page): Promise<string[]> {
return page.evaluate(() => {
const store = window.__store
if (!store) {
return []
}
return Object.keys(store.getState().unreadTerminalTabs)
})
}
async function isWorktreeUnread(page: Page, worktreeId: string): Promise<boolean> {
return page.evaluate((worktreeId) => {
const store = window.__store
if (!store) {
return false
}
const worktree = Object.values(store.getState().worktreesByRepo)
.flat()
.find((candidate) => candidate.id === worktreeId)
return worktree?.isUnread === true
}, worktreeId)
}
async function getTerminalTabTitle(
page: Page,
worktreeId: string,
tabId: string
): Promise<string | null> {
const tabs = await getWorktreeTabs(page, worktreeId)
return tabs.find((tab) => tab.id === tabId)?.title ?? null
}
async function hasPendingStartupCommand(page: Page, tabId: string): Promise<boolean> {
return page.evaluate((tabId) => {
const store = window.__store
if (!store) {
return false
}
return store.getState().pendingStartupByTabId[tabId] !== undefined
}, tabId)
}
type ParkableTabSetup = {
worktreeId: string
tabAId: string
tabAPtyId: string
}
// Why: every scenario starts from the same shape — tab A live in the active
// worktree; callers then create more tabs on top so tab A goes hidden.
async function setUpParkableTabA(page: Page): Promise<ParkableTabSetup> {
const worktreeId = await waitForActiveWorktree(page)
await skipUnlessParkingWired(page)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
const tabASnapshot = await waitForPaneIdentitySnapshot(page, 1)
const tabAPtyId = tabASnapshot.panes[0]?.ptyId
if (!tabAPtyId) {
throw new Error('parking spec tab A did not bind a PTY')
}
return {
worktreeId,
tabAId: tabASnapshot.tabId,
tabAPtyId
}
}
test.describe('Terminal hidden view parking', () => {
test('parks a hidden terminal tab and restores rich TUI output on reveal', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
await waitForSessionReady(orcaPage)
await enableTerminalParking(orcaPage)
const setup = await setUpParkableTabA(orcaPage)
const { worktreeId, tabAId, tabAPtyId } = setup
const runId = randomUUID()
const finalMarker = `PARKED_RESTORE_FINAL_${runId}_${PARKED_FRAME_COUNT - 1}`
const scriptPath = path.join(testRepoPath, `.orca-parked-rich-tui-${runId}.mjs`)
writeParkedFrameScript(scriptPath, runId)
try {
await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 15_000,
message: 'rich TUI final frame did not render while tab A was visible'
})
.toContain(finalMarker)
const tabBId = await createActiveTerminalTab(orcaPage, worktreeId)
const parkDetectedAfterMs = await waitForTabParked(orcaPage, tabAId)
const wiring = await readParkingWiring(orcaPage)
testInfo.annotations.push({
type: 'terminal-parking',
description: `parkDelayMs=${wiring.parkDelayMs ?? PARKING_DELAY_MS} parkDetectedAfterMs=${parkDetectedAfterMs}`
})
// Why: parking must be scoped to the hidden tab — the visible tab keeps
// a live pane manager and xterm.
const tabBState = await readTerminalTabViewState(orcaPage, tabBId)
expect(tabBState.hasManager).toBe(true)
expect(tabBState.paneCount).toBeGreaterThan(0)
await activateTerminalTab(orcaPage, tabAId)
await waitForActiveTerminalManager(orcaPage, 30_000)
const revealedSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
expect(revealedSnapshot.tabId).toBe(tabAId)
// Why: parking only tears down the renderer view; the PTY session must
// survive so reveal reattaches to the same shell.
expect(revealedSnapshot.panes[0]?.ptyId).toBe(tabAPtyId)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 15_000,
message: 'parked rich TUI frame did not restore when the tab was revealed'
})
.toContain(finalMarker)
const content = await getTerminalContent(orcaPage, 12_000)
expect(content).toContain(`Frame ${String(PARKED_FRAME_COUNT - 1).padStart(3, '0')}`)
expect(content).toContain('╭')
expect(content).toContain('├')
expect(content).toContain('█')
expect(content).not.toContain('Orca skipped hidden terminal output')
// Why: the typed marker only appears joined in command *output*, so this
// proves the revealed terminal accepts input end-to-end, not just echo.
const typedMarker = `PARKED_TYPED_OK_${runId}`
const typedProbeScript = `console.log('PARKED_TYPED_OK_' + '${runId}')`
await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(typedProbeScript)}\r`)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 10_000,
message: 'revealed terminal did not execute and display typed input'
})
.toContain(typedMarker)
const screenshotPath = testInfo.outputPath('parked-tab-restore-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('parked-tab-restore-final.png', {
path: screenshotPath,
contentType: 'image/png'
})
} finally {
rmSync(scriptPath, { force: true })
}
})
test('keeps bell and title side effects live while parked', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await enableTerminalParking(orcaPage)
const setup = await setUpParkableTabA(orcaPage)
const { worktreeId, tabAId, tabAPtyId } = setup
await createActiveTerminalTab(orcaPage, worktreeId)
await waitForTabParked(orcaPage, tabAId)
const runId = randomUUID()
const parkedTitle = `Parked side effects ${runId}`
const marker = `PARKED_SIDE_EFFECT_MARKER_${runId}`
// Why: OSC 0 title first, then a standalone BEL (the OSC terminator BEL
// must not count as a bell), then a content marker for the reveal check.
// The 30s keep-alive stops the shell prompt from overwriting the title
// before the store assertion lands.
const payload = `\x1b]0;${parkedTitle}\x07\x07${marker}\n`
const sideEffectScript = `process.stdout.write(${JSON.stringify(payload)}); setTimeout(() => process.exit(0), 30000)`
await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(sideEffectScript)}\r`)
await expect
.poll(() => getTerminalTabTitle(orcaPage, worktreeId, tabAId), {
timeout: 10_000,
message: 'parked OSC 0 title did not update the tab title in the store'
})
.toBe(parkedTitle)
await expect
.poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(tabAId), {
timeout: 10_000,
message: 'parked BEL did not mark the terminal tab unread'
})
.toBe(true)
await expect
.poll(() => isWorktreeUnread(orcaPage, worktreeId), {
timeout: 10_000,
message: 'parked BEL did not mark the worktree unread'
})
.toBe(true)
// Why: side effects must come from the pane-less watcher — the burst must
// not have woken the parked view back up.
expect((await readTerminalTabViewState(orcaPage, tabAId)).hasManager).toBe(false)
await activateTerminalTab(orcaPage, tabAId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 15_000,
message: 'parked side-effect marker did not restore when the tab was revealed'
})
.toContain(marker)
})
test('does not park excluded tabs', async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await enableTerminalParking(orcaPage)
const setup = await setUpParkableTabA(orcaPage)
const { worktreeId, tabAId } = setup
// Tab C: parking-excluded because it has a pending startup command. Queue
// it after the pane mounted so the mount-time consume cannot drain it.
const tabCId = await createActiveTerminalTab(orcaPage, worktreeId)
await orcaPage.evaluate((tabId) => {
const store = window.__store
if (!store) {
throw new Error('parking exclusion spec: window.__store is unavailable')
}
store.getState().queueTabStartupCommand(tabId, { command: 'echo parked-exclusion-probe' })
}, tabCId)
expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true)
// Tab B on top hides both A and C.
const tabBId = await createActiveTerminalTab(orcaPage, worktreeId)
await expect
.poll(() => getActiveTabId(orcaPage), {
timeout: 5_000,
message: 'tab B did not stay active while waiting on the parking window'
})
.toBe(tabBId)
// Why: tab A parking proves the machinery ran past the delay in this app
// instance, so the tab C assertion below is not vacuously green.
await waitForTabParked(orcaPage, tabAId)
await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3)
// Premise guard: nothing consumed the pending startup while hidden.
expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true)
const tabCState = await readTerminalTabViewState(orcaPage, tabCId)
expect(tabCState.hasManager).toBe(true)
expect(tabCState.paneCount).toBeGreaterThan(0)
})
// Drives 25 deterministic park→reveal cycles on a static rich TUI frame and
// asserts every reveal reproduces the SAME content the tab showed while it was
// continuously visible (the never-parked reference). This is the field-garble
// guard end-to-end: it exercises the real renderer teardown + HeadlessEmulator
// snapshot restore + PTY reattach path the fuzz suites model in isolation, and
// fails if any single cycle — or accumulated drift across 25 — garbles a cell.
test('reproduces a static frame byte-for-byte across 25 park/reveal cycles', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
await enableTerminalParking(orcaPage)
const setup = await setUpParkableTabA(orcaPage)
const { worktreeId, tabAId, tabAPtyId } = setup
const runId = randomUUID()
const marker = `CYCLE_REFERENCE_${runId}`
const scriptPath = path.join(testRepoPath, `.orca-cycle-reference-${runId}.mjs`)
writeCycleReferenceScript(scriptPath, runId)
try {
await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 15_000,
message: 'cycle reference frame did not render while tab A was visible'
})
.toContain(marker)
// Tab B stays visible whenever tab A is parked; toggling the active tab
// between them is the deterministic hide/reveal driver.
const tabBId = await createActiveTerminalTab(orcaPage, worktreeId)
// One park/reveal cycle to run the frame through the snapshot restore for a
// baseline. Why not compare against the visible-before-park content: an
// alt-screen restore deliberately drops the normal-buffer scrollback
// (serializeHeadlessTerminalBuffer forces scrollback 0 under alt), so the
// pre-park serialize carries the shell command echo the restore correctly
// omits — that is contract, not garble. Baselining after one reveal makes
// both sides pass through identical machinery, so any later diff is drift.
const runOneParkRevealCycle = async (cycle: number): Promise<string[]> => {
await activateTerminalTab(orcaPage, tabBId)
await waitForTabParked(orcaPage, tabAId)
await activateTerminalTab(orcaPage, tabAId)
await waitForActiveTerminalManager(orcaPage, 30_000)
const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1)
expect(revealed.panes[0]?.ptyId).toBe(tabAPtyId)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 15_000,
message: `cycle ${cycle}: reference frame did not restore on reveal`
})
.toContain(marker)
const rows = terminalContentRows(await getTerminalContent(orcaPage, 12_000))
// Garble sentinel: the hidden-skip banner must never appear.
expect(rows.join('\n')).not.toContain('Orca skipped hidden terminal output')
return rows
}
const referenceRows = await runOneParkRevealCycle(0)
expect(referenceRows.join('\n')).toContain(marker)
expect(referenceRows.join('\n')).toContain('╭')
// waitForTabParked (inside runOneParkRevealCycle) throws if the tab never
// parked, so reaching here means the machinery ran every cycle — no
// separate premise guard needed for a vacuous-green check.
const CYCLES = 25
const mismatches: string[] = []
for (let cycle = 1; cycle < CYCLES; cycle++) {
const rows = await runOneParkRevealCycle(cycle)
if (JSON.stringify(rows) !== JSON.stringify(referenceRows)) {
mismatches.push(
`cycle ${cycle}:\n expected: ${JSON.stringify(referenceRows)}\n actual: ${JSON.stringify(rows)}`
)
}
}
testInfo.annotations.push({
type: 'terminal-parking-cycles',
description: `cycles=${CYCLES} mismatches=${mismatches.length}`
})
expect(
mismatches,
`park/reveal drift across ${CYCLES} cycles:\n${mismatches.join('\n')}`
).toEqual([])
const screenshotPath = testInfo.outputPath('park-reveal-25-cycles-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('park-reveal-25-cycles-final.png', {
path: screenshotPath,
contentType: 'image/png'
})
} finally {
rmSync(scriptPath, { force: true })
}
})
})
+373
View File
@@ -0,0 +1,373 @@
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getActiveTabId,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActiveTerminalManager,
waitForPaneIdentitySnapshot
} from './helpers/terminal'
// Why: production cold-park hysteresis is 30s. The fast-park env override is
// scoped to this spec's app launches via orcaAppExtraEnv (same pattern as
// terminal-hidden-view-parking.spec.ts) so it cannot leak into other specs.
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
test.use({
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) },
// Why: without this switch Chromium quantizes performance.memory and only
// refreshes it every ~20 minutes, so both scenarios report the same stale
// launch-time bucket instead of a comparable heap figure.
orcaAppExtraArgs: ['--enable-precise-memory-info']
})
// Why: 8 hidden tabs is below the 12-tab hot-retain limit, but that limit
// never retains anything here — the ORCA_E2E_TERMINAL_PARKING_DELAY_MS
// collapse (terminal-parking-e2e-overrides.ts) shrinks hotRetainMs to the
// same delay as coldParkDelayMs, and the policy cold-parks any tab hidden
// past hotRetainMs before the retain-count limit is even consulted. So all 8
// park without needing 14 tabs or extra policy knobs.
const SCROLLBACK_TAB_COUNT = 8
const SCROLLBACK_LINE_COUNT = 3000
const PARK_SETTLE_MS = 2_000
const HEAP_SAMPLE_COUNT = 5
const HEAP_SAMPLE_INTERVAL_MS = 250
// Why: each test launches a fresh app, fills 8 terminals with ~3000 lines of
// scrollback each, then waits out the parking window — well past the default
// 120s per-test budget.
const PARKED_MEMORY_TEST_TIMEOUT_MS = 300_000
// Why: mixed-width content (ASCII, CJK wide cells, emoji, box drawing) makes
// each xterm hold realistic narrow+wide buffer rows, so released parked-tab
// memory reflects real agent output rather than uniform filler.
function writeScrollbackFillScript(scriptPath: string, runId: string): void {
const script = [
`const tabIndex = process.argv[2] ?? '0'`,
`const wide = '統合端末記憶計測'`,
`const emoji = ['🟢', '🟡', '🔵', '🟣']`,
`const lines = []`,
`for (let i = 0; i < ${SCROLLBACK_LINE_COUNT}; i += 1) {`,
` const ascii = ('tab ' + tabIndex + ' line ' + String(i).padStart(4, '0') + ' ').padEnd(48, 'abcdefghijklmnopqrstuvwxyz')`,
` const box = '│' + '─'.repeat(8 + (i % 24)) + '│'`,
` lines.push(ascii + ' ' + wide.repeat(1 + (i % 3)) + ' ' + emoji[i % 4] + ' ' + box)`,
`}`,
`process.stdout.write(lines.join('\\n') + '\\n')`,
`process.stdout.write('PARKED_MEMORY_FILL_DONE_${runId}_' + tabIndex + '\\n')`
].join('\n')
writeFileSync(scriptPath, `${script}\n`)
}
// Why: the spec lands ahead of the feature wiring in some merge orders. Skip
// (rather than fail) when the app under test does not expose the parking
// debug handle, mirroring terminal-hidden-view-parking.spec.ts.
async function skipUnlessParkingWired(page: Page): Promise<void> {
const deadline = Date.now() + 2_000
let present = await page.evaluate(() => window.__terminalParkingDebug !== undefined)
while (!present && Date.now() < deadline) {
await page.waitForTimeout(250)
present = await page.evaluate(() => window.__terminalParkingDebug !== undefined)
}
test.skip(
!present,
'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)'
)
}
type TerminalTabViewState = {
hasManager: boolean
paneCount: number
}
// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so
// a missing manager is the observable signal that the tab's xterm was parked.
async function readTerminalTabViewState(page: Page, tabId: string): Promise<TerminalTabViewState> {
return page.evaluate((tabId) => {
const manager = window.__paneManagers?.get(tabId)
return {
hasManager: manager !== undefined,
paneCount: manager?.getPanes?.().length ?? 0
}
}, tabId)
}
async function countMountedPaneManagers(page: Page, tabIds: string[]): Promise<number> {
return page.evaluate(
(tabIds) => tabIds.filter((tabId) => window.__paneManagers?.get(tabId) !== undefined).length,
tabIds
)
}
async function waitForTabsParked(page: Page, tabIds: string[]): Promise<void> {
await expect
.poll(() => countMountedPaneManagers(page, tabIds), {
timeout: Math.max(30_000, PARKING_DELAY_MS * 10),
message: 'hidden scrollback tabs did not all park (pane managers still mounted)'
})
.toBe(0)
}
type ScrollbackTab = {
tabId: string
ptyId: string
}
async function createActiveTerminalTab(page: Page, worktreeId: string): Promise<ScrollbackTab> {
const tabId = await page.evaluate((worktreeId) => {
const store = window.__store
if (!store) {
throw new Error('createActiveTerminalTab: window.__store is unavailable')
}
const state = store.getState()
const tab = state.createTab(worktreeId, undefined, undefined, { activate: true })
state.setActiveTab(tab.id)
state.setActiveTabType('terminal')
return tab.id
}, worktreeId)
await expect
.poll(() => getActiveTabId(page), {
timeout: 5_000,
message: 'newly created terminal tab did not become active'
})
.toBe(tabId)
await waitForActiveTerminalManager(page, 30_000)
const snapshot = await waitForPaneIdentitySnapshot(page, 1)
const ptyId = snapshot.panes[0]?.ptyId
if (snapshot.tabId !== tabId || !ptyId) {
throw new Error('createActiveTerminalTab: new tab did not bind a PTY')
}
return { tabId, ptyId }
}
async function fillActiveTerminalWithScrollback(
page: Page,
ptyId: string,
scriptPath: string,
tabIndex: number,
runId: string
): Promise<void> {
await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)} ${tabIndex}\r`)
await expect
.poll(() => getTerminalContent(page, 4_000), {
timeout: 30_000,
message: `scrollback fill marker for tab ${tabIndex} did not render`
})
.toContain(`PARKED_MEMORY_FILL_DONE_${runId}_${tabIndex}`)
}
type ScrollbackTabSetup = {
worktreeId: string
scrollbackTabs: ScrollbackTab[]
}
// Why: each tab generates its scrollback while visible, so every xterm holds
// the full buffer before going hidden — the hidden-delivery gate never gets a
// chance to drop the output the memory comparison depends on.
async function setUpScrollbackTabs(
page: Page,
scriptPath: string,
runId: string
): Promise<ScrollbackTabSetup> {
const worktreeId = await waitForActiveWorktree(page)
await skipUnlessParkingWired(page)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
const baselineSnapshot = await waitForPaneIdentitySnapshot(page, 1)
const baselinePtyId = baselineSnapshot.panes[0]?.ptyId
if (!baselinePtyId) {
throw new Error('parked memory spec: baseline terminal tab did not bind a PTY')
}
const scrollbackTabs: ScrollbackTab[] = [{ tabId: baselineSnapshot.tabId, ptyId: baselinePtyId }]
await fillActiveTerminalWithScrollback(page, baselinePtyId, scriptPath, 0, runId)
for (let tabIndex = 1; tabIndex < SCROLLBACK_TAB_COUNT; tabIndex += 1) {
const tab = await createActiveTerminalTab(page, worktreeId)
scrollbackTabs.push(tab)
await fillActiveTerminalWithScrollback(page, tab.ptyId, scriptPath, tabIndex, runId)
}
return { worktreeId, scrollbackTabs }
}
type ParkedMemoryMetrics = {
heapUsedMB: number
liveTerminals: number
livePaneManagers: number
}
// Why: usedJSHeapSize only drops after a GC, so force one over CDP (best
// effort) and take the min of several settled samples — the min reflects
// retained heap instead of allocation noise between collections. Note xterm
// buffer rows are typed-array backing stores outside the V8 heap, so the
// liveTerminals/livePaneManagers counts are the strong release signal and the
// heap figure tracks only the on-heap share.
async function sampleParkedMemoryMetrics(page: Page): Promise<ParkedMemoryMetrics> {
await page.waitForTimeout(PARK_SETTLE_MS)
try {
const session = await page.context().newCDPSession(page)
await session.send('HeapProfiler.collectGarbage')
await session.detach()
} catch {
// GC over CDP is a measurement-fidelity improvement, not a gate.
}
let minHeapBytes: number | null = null
for (let sample = 0; sample < HEAP_SAMPLE_COUNT; sample += 1) {
const heapBytes = await page.evaluate(() => {
const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory
return memory?.usedJSHeapSize ?? null
})
if (heapBytes !== null) {
minHeapBytes = minHeapBytes === null ? heapBytes : Math.min(minHeapBytes, heapBytes)
}
await page.waitForTimeout(HEAP_SAMPLE_INTERVAL_MS)
}
if (minHeapBytes === null) {
throw new Error('sampleParkedMemoryMetrics: performance.memory.usedJSHeapSize is unavailable')
}
const liveCounts = await page.evaluate(() => ({
liveTerminals: document.querySelectorAll('.xterm').length,
livePaneManagers: window.__paneManagers?.size ?? 0
}))
return { heapUsedMB: minHeapBytes / (1024 * 1024), ...liveCounts }
}
function formatParkedMemoryAnnotation(metrics: ParkedMemoryMetrics, parkedTabs: number): string {
return [
`panes=${SCROLLBACK_TAB_COUNT}`,
`parkedTabs=${parkedTabs}`,
`heapUsedMB=${metrics.heapUsedMB.toFixed(1)}`,
`liveTerminals=${metrics.liveTerminals}`,
`livePaneManagers=${metrics.livePaneManagers}`
].join(' ')
}
// Why: this standalone extraction ships terminalHiddenViewParking default-OFF
// (opt-in until the memory win is soak-confirmed); PR #7214 later flips the
// default to true, making this enable a no-op. Same mutation path as the
// kill-switch test in terminal-parked-memory.spec.ts.
async function enableTerminalParking(page: Page): Promise<void> {
await page.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('parking spec: window.__store is unavailable')
}
await store.getState().updateSettings({ terminalHiddenViewParking: true })
})
}
test.describe('Terminal parked memory', () => {
test('releases renderer terminal memory when hidden tabs park', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS)
await waitForSessionReady(orcaPage)
await enableTerminalParking(orcaPage)
const runId = randomUUID()
const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`)
writeScrollbackFillScript(scriptPath, runId)
try {
const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId)
// A fresh 9th tab hides all 8 scrollback tabs.
const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId)
await waitForTabsParked(
orcaPage,
scrollbackTabs.map((tab) => tab.tabId)
)
const metrics = await sampleParkedMemoryMetrics(orcaPage)
testInfo.annotations.push({
type: 'opencode-parked-memory',
description: formatParkedMemoryAnnotation(metrics, scrollbackTabs.length)
})
// Structural assertions: all 8 parked (managers gone), and the only
// live xterm/pane manager belongs to the visible tab.
for (const tab of scrollbackTabs) {
expect((await readTerminalTabViewState(orcaPage, tab.tabId)).hasManager).toBe(false)
}
const visibleState = await readTerminalTabViewState(orcaPage, visibleTab.tabId)
expect(visibleState.hasManager).toBe(true)
expect(visibleState.paneCount).toBeGreaterThan(0)
// Why: design invariant 5 — renderer terminal views scale with visible
// panes, so parked tabs must leave no xterm DOM behind.
expect(metrics.liveTerminals).toBe(visibleState.paneCount)
expect(metrics.livePaneManagers).toBe(1)
} finally {
rmSync(scriptPath, { force: true })
}
})
test('retains terminal views when parking is disabled', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS)
await waitForSessionReady(orcaPage)
// Why: settings.terminalHiddenViewParking === false is the design-doc
// kill switch. updateSettings persists it through window.api.settings.set
// and updates the store slice the cold-park hook subscribes to — the same
// mutation path dead-terminal-repro.spec.ts uses, so no extra launch-env
// wiring is needed.
await orcaPage.evaluate(async () => {
const store = window.__store
if (!store) {
throw new Error('parked memory spec: window.__store is unavailable')
}
await store.getState().updateSettings({ terminalHiddenViewParking: false })
})
await expect
.poll(
() =>
orcaPage.evaluate(() => window.__store?.getState().settings?.terminalHiddenViewParking),
{ timeout: 5_000, message: 'terminalHiddenViewParking kill switch did not persist' }
)
.toBe(false)
const runId = randomUUID()
const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`)
writeScrollbackFillScript(scriptPath, runId)
try {
const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId)
const scrollbackTabIds = scrollbackTabs.map((tab) => tab.tabId)
const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId)
// Why: with parking enabled these tabs park within ~1x the collapsed
// delay (the first test proves the machinery in this app build), so
// surviving 3x the delay shows the kill switch held.
await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3)
expect(await countMountedPaneManagers(orcaPage, scrollbackTabIds)).toBe(SCROLLBACK_TAB_COUNT)
const metrics = await sampleParkedMemoryMetrics(orcaPage)
testInfo.annotations.push({
type: 'opencode-parked-memory-disabled',
description: formatParkedMemoryAnnotation(metrics, 0)
})
// Structural assertions: every hidden tab keeps its pane manager and
// xterm; nothing parked even after the settle + sampling window.
for (const tab of scrollbackTabs) {
const state = await readTerminalTabViewState(orcaPage, tab.tabId)
expect(state.hasManager).toBe(true)
expect(state.paneCount).toBeGreaterThan(0)
}
expect((await readTerminalTabViewState(orcaPage, visibleTab.tabId)).hasManager).toBe(true)
expect(metrics.livePaneManagers).toBe(SCROLLBACK_TAB_COUNT + 1)
expect(metrics.liveTerminals).toBe(SCROLLBACK_TAB_COUNT + 1)
} finally {
rmSync(scriptPath, { force: true })
}
})
})
@@ -0,0 +1,220 @@
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
ensureTerminalVisible,
getAllWorktreeIds,
switchToWorktree,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import {
getTerminalContent,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
type SleepWakeTerminalDebug = {
activeTabId: string | null
activeWorktreeId: string | null
tabs: {
id: string
ptyId?: string
generation?: number
pendingActivationSpawn?: boolean | number
}[]
ptyIdsByTabId: Record<string, string[]>
ptyIdsByLeafIdByTabId: Record<string, Record<string, string>>
}
async function sleepWorktreeTerminals(page: Page, worktreeId: string): Promise<void> {
await page.evaluate(async (id) => {
const store = window.__store
if (!store) {
throw new Error('store unavailable')
}
const state = store.getState()
await state.shutdownWorktreeBrowsers(id)
await state.shutdownWorktreeTerminals(id, { keepIdentifiers: true })
}, worktreeId)
}
async function readLivePtyCountForWorktree(page: Page, worktreeId: string): Promise<number> {
return page.evaluate((id) => {
const store = window.__store
if (!store) {
return 0
}
const state = store.getState()
const tabs = state.tabsByWorktree[id] ?? []
return tabs.reduce((count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0), 0)
}, worktreeId)
}
async function readSleepWakeTerminalDebug(
page: Page,
worktreeId: string
): Promise<SleepWakeTerminalDebug> {
return page.evaluate((id) => {
const store = window.__store
if (!store) {
return {
activeTabId: null,
activeWorktreeId: null,
tabs: [],
ptyIdsByTabId: {},
ptyIdsByLeafIdByTabId: {}
}
}
const state = store.getState()
const tabs = state.tabsByWorktree[id] ?? []
return {
activeTabId: state.activeTabId,
activeWorktreeId: state.activeWorktreeId,
tabs: tabs.map((tab) => ({
id: tab.id,
ptyId: tab.ptyId,
generation: tab.generation,
pendingActivationSpawn: tab.pendingActivationSpawn
})),
ptyIdsByTabId: Object.fromEntries(
tabs.map((tab) => [tab.id, state.ptyIdsByTabId[tab.id] ?? []])
),
ptyIdsByLeafIdByTabId: Object.fromEntries(
tabs.map((tab) => [tab.id, state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}])
)
}
}, worktreeId)
}
async function mainSnapshotContains(page: Page, ptyId: string, text: string): Promise<boolean> {
return page.evaluate(
async ({ targetPtyId, expectedText }) => {
const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, {
scrollbackRows: 200
})
return snapshot?.data.includes(expectedText) ?? false
},
{ targetPtyId: ptyId, expectedText: text }
)
}
function richSleepWakePayload(runId: string): string {
const shortId = runId.slice(0, 8)
return [
'\x1b[?2026h',
'\x1b[2J\x1b[H',
'╭────────────────────────────────────────────╮',
`│ sleep wake restore ${shortId} 😀 │`,
'├────────────┬───────────────┬───────────────┤',
'│ agent │ status │ output │',
'├────────────┼───────────────┼───────────────┤',
`│ codex-${shortId.slice(0, 4)} │ thinking │ box/table ok │`,
'│ opencode │ streaming │ unicode ✓ │',
'│ shell │ idle │ prompt ready │',
'╰────────────┴───────────────┴───────────────╯',
`SLEEP_WAKE_RESTORE_${runId}`,
`SLEEP_WAKE_TABLE_${runId}`,
'\x1b[?2026l'
].join('\r\n')
}
function sleepWakeExpectedMarkers(runId: string): string[] {
return [
`SLEEP_WAKE_RESTORE_${runId}`,
`SLEEP_WAKE_TABLE_${runId}`,
'box/table ok',
'unicode ✓',
'prompt ready'
]
}
function writeSleepWakePayloadScript(scriptPath: string, payload: string): void {
const encodedPayload = Buffer.from(payload, 'utf8').toString('base64')
writeFileSync(
scriptPath,
`process.stdout.write(Buffer.from(${JSON.stringify(encodedPayload)}, 'base64').toString('utf8'))\n`,
'utf8'
)
}
test.describe('Terminal sleep wake restore', () => {
test('restores slept terminal output and accepts fresh input after wake', async ({
orcaPage,
testRepoPath
}) => {
await waitForSessionReady(orcaPage)
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
(id) => id !== firstWorktreeId
)
test.skip(!secondWorktreeId, 'sleep wake restore needs the seeded secondary worktree')
if (!secondWorktreeId) {
return
}
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
const runId = randomUUID()
const restoreMarker = `SLEEP_WAKE_RESTORE_${runId}`
const freshMarker = `SLEEP_WAKE_FRESH_${runId}`
const expectedMarkers = sleepWakeExpectedMarkers(runId)
const scriptPath = path.join(testRepoPath, `.orca-sleep-wake-restore-${runId}.mjs`)
writeSleepWakePayloadScript(scriptPath, richSleepWakePayload(runId))
try {
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
await waitForTerminalOutput(orcaPage, restoreMarker, 10_000, 20_000)
const beforeSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId)
for (const marker of expectedMarkers) {
expect(await mainSnapshotContains(orcaPage, ptyId, marker)).toBe(true)
}
await switchToWorktree(orcaPage, firstWorktreeId)
await sleepWorktreeTerminals(orcaPage, secondWorktreeId)
const afterSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId)
await expect
.poll(() => readLivePtyCountForWorktree(orcaPage, secondWorktreeId), {
timeout: 10_000,
message: 'sleep did not release live PTYs for the background worktree'
})
.toBe(0)
await switchToWorktree(orcaPage, secondWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const awakePtyId = await waitForActivePanePtyId(orcaPage)
const afterWakeDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId)
const awakeTerminalContent = await getTerminalContent(orcaPage, 20_000)
for (const marker of expectedMarkers) {
expect
.soft(awakeTerminalContent.includes(marker), {
message: JSON.stringify(
{
missingMarker: marker,
ptyId,
awakePtyId,
beforeSleepDebug,
afterSleepDebug,
afterWakeDebug,
terminalTail: awakeTerminalContent.slice(-2000)
},
null,
2
)
})
.toBe(true)
}
await waitForTerminalOutput(orcaPage, restoreMarker, 15_000, 20_000)
await sendToTerminal(orcaPage, awakePtyId, `printf '\\n${freshMarker}\\n'\r`)
await waitForTerminalOutput(orcaPage, freshMarker, 10_000, 20_000)
} finally {
rmSync(scriptPath, { force: true })
}
})
})