diff --git a/src/main/daemon/daemon-pty-adapter-sleep-cwd.node-pty.test.ts b/src/main/daemon/daemon-pty-adapter-sleep-cwd.node-pty.test.ts new file mode 100644 index 00000000000..a48c656019a --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-sleep-cwd.node-pty.test.ts @@ -0,0 +1,98 @@ +import './mock-descendant-sweep' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as pty from 'node-pty' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { loadNodePtyForTests } from '../orcad/orcad-node-slot-fixture' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { + startDaemonAdapterHarness, + type DaemonAdapterHarness +} from './daemon-pty-adapter-test-harness' +import { getHistorySessionDirName } from './history-paths' +import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle' + +const describePosix = process.platform === 'win32' ? describe.skip : describe +const SESSION_ID = 'wt-1::/project@@sleep-cwd' + +// verify-15b: a WSL host pane (orcad on Linux) woke in the project root after `cd deep`. +describePosix('a real bash slept and woken by the daemon', () => { + let nodePty: typeof pty + let harness: DaemonAdapterHarness | null = null + let shells: pty.IPty[] = [] + let root = '' + let adapter: DaemonPtyAdapter | null = null + + beforeAll(async () => { + nodePty = await loadNodePtyForTests() + }) + afterEach(async () => { + for (const shell of shells.splice(0)) { + try { + shell.kill('SIGKILL') + } catch { + // Already exited. + } + } + adapter?.dispose() + await harness?.server.shutdown() + rmSync(harness?.dir ?? '', { recursive: true, force: true }) + rmSync(root, { recursive: true, force: true }) + }) + + it('wakes in the folder bash was in, though bash prints no OSC 7', async () => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-sleep-cwd-'))) + const deep = join(root, 'deep') + mkdirSync(deep) + const spawnedCwds: string[] = [] + let output = '' + harness = await startDaemonAdapterHarness((opts) => { + const cwd = opts.cwd ?? root + spawnedCwds.push(cwd) + const shell = nodePty.spawn('/bin/bash', ['--norc', '--noprofile', '-i'], { + cwd, + cols: 80, + rows: 24, + env: { TERM: 'xterm-256color', PATH: '/usr/bin:/bin', PS1: 'PROMPT$ ' } + }) + shell.onData((data) => { + output += data + }) + shells.push(shell) + return createDaemonPtySubprocessHandle({ + process: shell, + shellPath: '/bin/bash', + spawnCwd: cwd, + env: {}, + startupCommandDeliveredInShellArgs: false, + reportsChildExitStatus: true, + sessionId: SESSION_ID, + startupAgentRecognition: null + }) + }) + const historyDir = join(harness.dir, 'history') + adapter = new DaemonPtyAdapter({ + socketPath: harness.socketPath, + tokenPath: harness.tokenPath, + historyPath: historyDir + }) + + await adapter.spawn({ cols: 80, rows: 24, cwd: root, sessionId: SESSION_ID }) + await vi.waitFor(() => expect(output).toContain('PROMPT$'), { timeout: 5000 }) + adapter.write(SESSION_ID, 'cd deep && echo IN-DEEP\n') + await vi.waitFor(() => expect(output).toMatch(/IN-DEEP\r?\n/), { timeout: 5000 }) + + await adapter.shutdown(SESSION_ID, { immediate: true, keepHistory: true }) + const saved = JSON.parse( + readFileSync( + join(historyDir, getHistorySessionDirName(SESSION_ID), 'checkpoint.json'), + 'utf8' + ) + ) + expect(saved.cwd).toBe(deep) + + await adapter.spawn({ cols: 80, rows: 24, cwd: root, sessionId: SESSION_ID }) + expect(spawnedCwds.at(-1)).toBe(deep) + }) +}) diff --git a/src/main/daemon/daemon-pty-adapter-sleep-cwd.test.ts b/src/main/daemon/daemon-pty-adapter-sleep-cwd.test.ts new file mode 100644 index 00000000000..061a9844fde --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-sleep-cwd.test.ts @@ -0,0 +1,179 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { readFileSync, realpathSync, rmSync } from 'node:fs' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonClient } from './client' +import type { DaemonServer } from './daemon-server' +import { getHistorySessionDirName } from './history-paths' +import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness' +import type * as DaemonHealthModule from './daemon-health' +import type * as DaemonTccAttributionModule from './daemon-tcc-attribution' +import type * as ProcessCwdModule from '../providers/process-cwd' + +const { + getMacDaemonSystemResolverHealthMock, + getMacDaemonTccAttributionHealthMock, + resolveProcessCwdSpy +} = vi.hoisted(() => ({ + resolveProcessCwdSpy: vi.fn(), + getMacDaemonSystemResolverHealthMock: vi.fn( + async (): Promise<'unknown' | 'unhealthy'> => 'unknown' + ), + getMacDaemonTccAttributionHealthMock: vi.fn( + async (): Promise<'intact' | 'severed' | 'unknown'> => 'unknown' + ) +})) + +vi.mock('../providers/process-cwd', async (importOriginal) => { + const actual = await importOriginal() + resolveProcessCwdSpy.mockImplementation(actual.resolveProcessCwd) + return { ...actual, resolveProcessCwd: resolveProcessCwdSpy } +}) + +vi.mock('./daemon-health', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock + } +}) + +vi.mock('./daemon-tcc-attribution', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getMacDaemonTccAttributionHealth: getMacDaemonTccAttributionHealthMock + } +}) + +// verify-15b: a WSL host's pane (orcad on Linux, bash, no OSC 7) woke in the project root after `cd deep`. +describe('a native shell slept and woken', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + let adapter: DaemonPtyAdapter + let spawnedCwds: (string | undefined)[] + let lastSubprocess: ReturnType + + beforeEach(async () => { + spawnedCwds = [] + const harness = await startDaemonAdapterHarness((opts) => { + spawnedCwds.push(opts.cwd) + // A real pid, so the daemon's cwd lookup reads this process's folder, as for a live shell. + lastSubprocess = Object.assign(createMockSubprocess(), { pid: process.pid }) + return lastSubprocess + }) + dir = harness.dir + socketPath = harness.socketPath + tokenPath = harness.tokenPath + server = harness.server + adapter = harness.adapter + getMacDaemonSystemResolverHealthMock.mockResolvedValue('unknown') + getMacDaemonTccAttributionHealthMock.mockResolvedValue('unknown') + }) + + afterEach(async () => { + resolveProcessCwdSpy.mockClear() + adapter?.dispose() + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + it('wakes in the folder the shell was in at Sleep, not where it was spawned', async () => { + const historyDir = join(dir, 'history') + const sessionId = 'wt-1::/project@@sleep1' + const shellFolder = realpathSync(process.cwd()) + const history = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId }) + lastSubprocess._simulateData('user@host:~/project$ cd deep\r\nuser@host:~/project/deep$ ') + await history.shutdown(sessionId, { immediate: true, keepHistory: true }) + + const saved = JSON.parse( + readFileSync(join(historyDir, getHistorySessionDirName(sessionId), 'checkpoint.json'), 'utf8') + ) + expect(realpathSync(saved.cwd)).toBe(shellFolder) + await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId }) + expect(realpathSync(spawnedCwds.at(-1)!)).toBe(shellFolder) + history.dispose() + }) + + // The process lookup execFile()s lsof, which Windows lacks; Sleep there keeps only the OSC 7 cwd. + it('on Windows, Sleep without OSC 7 never looks up the process', async () => { + const realPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + try { + const historyDir = join(dir, 'history') + const sessionId = 'wt-1::/project@@sleep-win' + const history = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId }) + lastSubprocess._simulateData('PS C:\\project> cd deep\r\nPS C:\\project\\deep> ') + await history.shutdown(sessionId, { immediate: true, keepHistory: true }) + + const saved = JSON.parse( + readFileSync( + join(historyDir, getHistorySessionDirName(sessionId), 'checkpoint.json'), + 'utf8' + ) + ) + expect(resolveProcessCwdSpy).not.toHaveBeenCalled() + expect(saved.cwd).toBe(dir) + history.dispose() + } finally { + Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }) + } + }) + + async function sleepAndWake(sessionId: string, deadlineMs?: number) { + const historyDir = join(dir, 'history') + const history = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId }) + lastSubprocess._simulateData('user@host:~/project$ ') + await history.shutdown(sessionId, { immediate: true, keepHistory: true, deadlineMs }) + const saved = JSON.parse( + readFileSync(join(historyDir, getHistorySessionDirName(sessionId), 'checkpoint.json'), 'utf8') + ) + await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId }) + history.dispose() + return { savedCwd: saved.cwd, wokeCwd: spawnedCwds.at(-1) } + } + + it('wakes in the spawn folder when the shell folder was deleted', async () => { + resolveProcessCwdSpy.mockResolvedValueOnce(`${dir}/deep (deleted)`) + const { savedCwd, wokeCwd } = await sleepAndWake('wt-1::/project@@sleep-deleted') + expect(resolveProcessCwdSpy).toHaveBeenCalled() + expect(savedCwd).toBe(dir) + expect(wokeCwd).toBe(dir) + }) + + it('wakes in the spawn folder when the shell folder is unreadable', async () => { + resolveProcessCwdSpy.mockResolvedValueOnce('') + const { savedCwd, wokeCwd } = await sleepAndWake('wt-1::/project@@sleep-unreadable') + expect(resolveProcessCwdSpy).toHaveBeenCalled() + expect(savedCwd).toBe(dir) + expect(wokeCwd).toBe(dir) + }) + + it('gives a hung folder read a quarter of the Sleep deadline, then checkpoints', async () => { + // Freeze only the clock, so the read's budget is exact while socket timers stay real. + vi.useFakeTimers({ toFake: ['Date'] }) + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request') + try { + const now = Date.now() + resolveProcessCwdSpy.mockImplementationOnce(() => new Promise(() => {})) + const { savedCwd } = await sleepAndWake('wt-1::/project@@sleep-hung', now + 2_000) + expect(resolveProcessCwdSpy).toHaveBeenCalled() + const cwdTimeouts = requestSpy.mock.calls + .filter(([type]) => type === 'getCwd') + .map((c) => c[2]) + expect(cwdTimeouts).toEqual([500]) + expect(savedCwd).toBe(dir) + } finally { + requestSpy.mockRestore() + vi.useRealTimers() + } + }) +}) diff --git a/src/main/daemon/daemon-pty-session-control.ts b/src/main/daemon/daemon-pty-session-control.ts index 818a5f4df4c..8b01ebb2ebd 100644 --- a/src/main/daemon/daemon-pty-session-control.ts +++ b/src/main/daemon/daemon-pty-session-control.ts @@ -4,7 +4,7 @@ import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version' import { readDaemonAppliedPtySize, type DaemonAppliedPtySize } from './daemon-pty-applied-size' import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors' import { DaemonPtySessionInput } from './daemon-pty-session-input' -import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline' +import { remainingDaemonRequestTimeoutMs, teardownReadBudgetMs } from './daemon-request-deadline' import type { ColdRestoreInfo } from './history-reader' import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' import { readDaemonWslSleepCwd } from './daemon-wsl-sleep-cwd' @@ -15,6 +15,8 @@ import type { PtySpawnResult } from '../providers/pty-provider-contract' export const LIVENESS_PROBE_TIMEOUT_MS = 2_000 const MAX_TOMBSTONES = 1000 +// A busy daemon must not eat the final checkpoint's share of the Sleep deadline. +const LIVE_SLEEP_CWD_BUDGET_MS = 1_500 export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { protected abstract adoptListedSessionForSleep( @@ -137,7 +139,7 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { }, opts.deadlineMs ) - : null + : await this.readLiveSleepCwd(id, opts.deadlineMs) const committed = await this.runExclusiveCheckpoint( async () => { // Set inside: a caller past its deadline leaves this checkpoint running without it. @@ -278,6 +280,29 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { } } + /** + * A native shell's folder at Sleep (OSC 7, else its process's cwd). Bash prints no OSC 7, so + * without this the checkpoint keeps the spawn folder and Wake opens there (e.g. on a Linux host). + */ + private async readLiveSleepCwd(id: string, deadlineMs?: number): Promise { + // Windows has no process-cwd lookup (it would spawn a missing lsof); the snapshot's OSC 7 cwd stands. + const budget = teardownReadBudgetMs(LIVE_SLEEP_CWD_BUDGET_MS, deadlineMs) + if (process.platform === 'win32' || budget <= 0 || !this.activeSessionIds.has(id)) { + return null + } + try { + const { cwd } = await this.client.request<{ cwd: string | null }>( + 'getCwd', + { sessionId: id }, + budget + ) + // A removed folder reads back from /proc as ` (deleted)`, which no shell can return to. + return cwd && !cwd.endsWith(' (deleted)') ? cwd : null + } catch { + return null + } + } + async getInitialCwd(id: string): Promise { return this.initialCwds.get(id) ?? '' } diff --git a/src/main/daemon/daemon-request-deadline.ts b/src/main/daemon/daemon-request-deadline.ts index d98c1c5f7dd..ce91375e776 100644 --- a/src/main/daemon/daemon-request-deadline.ts +++ b/src/main/daemon/daemon-request-deadline.ts @@ -4,6 +4,13 @@ export function remainingDaemonRequestTimeoutMs( return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now()) } +// A read ahead of teardown gets at most a quarter of what remains, leaving the rest for checkpoint and kill. +export function teardownReadBudgetMs(capMs: number, deadlineMs: number | undefined): number { + return Math.floor( + Math.min(capMs, deadlineMs === undefined ? capMs : (deadlineMs - Date.now()) * 0.25) + ) +} + // The caller's wait is bounded without cancelling durable work, which keeps running and committing. export async function awaitDaemonWorkWithinCallerDeadline( work: Promise, diff --git a/src/main/daemon/daemon-wsl-sleep-cwd.ts b/src/main/daemon/daemon-wsl-sleep-cwd.ts index cdfd291e560..5b711bbc85b 100644 --- a/src/main/daemon/daemon-wsl-sleep-cwd.ts +++ b/src/main/daemon/daemon-wsl-sleep-cwd.ts @@ -4,22 +4,17 @@ import { readWslGuestPaneProcesses, readWslGuestPaneShellCwd } from '../wsl/wsl-guest-pane-processes' +import { teardownReadBudgetMs } from './daemon-request-deadline' // Reserve teardown time for the final checkpoint and kill when a distro does not answer. const READ_BUDGET_MS = 3_000 -const READ_SHARE_OF_DEADLINE = 0.25 /** The shell's folder in the bridge's UNC spelling, which wake reads; else null. */ export async function readDaemonWslSleepCwd( pane: { paneKey: string; wslDistro: string; live: boolean }, deadlineMs?: number ): Promise { - const budget = Math.floor( - Math.min( - READ_BUDGET_MS, - deadlineMs === undefined ? READ_BUDGET_MS : (deadlineMs - Date.now()) * READ_SHARE_OF_DEADLINE - ) - ) + const budget = teardownReadBudgetMs(READ_BUDGET_MS, deadlineMs) if (process.platform !== 'win32' || budget <= 0) { return null } diff --git a/src/main/ipc/pty/pane/terminal-spawn-restore.test.ts b/src/main/ipc/pty/pane/terminal-spawn-restore.test.ts new file mode 100644 index 00000000000..08f8647e00e --- /dev/null +++ b/src/main/ipc/pty/pane/terminal-spawn-restore.test.ts @@ -0,0 +1,43 @@ +// verify-15b: a WSL host pane's wake joined the new prompt onto the restored one. +import { Terminal } from '@xterm/headless' +import { describe, expect, it, vi } from 'vitest' +import type { PtySpawnResult } from '../../../providers/pty-provider-contract' +import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' +import { seedHeadlessTerminalFromSpawnResult } from './terminal-spawn-restore' + +async function rowsAfter(seed: string, liveOutput: string, rows: number): Promise { + const terminal = new Terminal({ cols: 40, rows, scrollback: 100, allowProposedApi: true }) + await new Promise((resolve) => terminal.write(seed + liveOutput, resolve)) + const lines: string[] = [] + for (let i = 0; i < terminal.buffer.active.length; i++) { + lines.push(terminal.buffer.active.getLine(i)?.translateToString(true).trimEnd() ?? '') + } + terminal.dispose() + return lines.filter((line) => line !== '') +} + +describe('seeding the host copy from a cold restore', () => { + it('parks the cursor below the restored history, so the new shell’s prompt has its own row', async () => { + const seedHeadlessTerminal = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seed reads only seedHeadlessTerminal from the runtime. + const runtime = { seedHeadlessTerminal } as unknown as OrcaRuntimeService + const result: PtySpawnResult = { + id: 'host-pty', + coldRestore: { + scrollback: 'user@host:~/p8$ cd deep\r\nuser@host:~/p8/deep$ ', + cwd: '/home/user/p8/deep', + cols: 40, + rows: 6 + } + } + + seedHeadlessTerminalFromSpawnResult(runtime, result, null) + + const [, seed] = seedHeadlessTerminal.mock.calls[0]! + expect(await rowsAfter(seed, 'user@host:~/p8/deep$ ', 6)).toEqual([ + 'user@host:~/p8$ cd deep', + 'user@host:~/p8/deep$', + 'user@host:~/p8/deep$' + ]) + }) +}) diff --git a/src/main/ipc/pty/pane/terminal-spawn-restore.ts b/src/main/ipc/pty/pane/terminal-spawn-restore.ts index 9a34bcd33da..26860786137 100644 --- a/src/main/ipc/pty/pane/terminal-spawn-restore.ts +++ b/src/main/ipc/pty/pane/terminal-spawn-restore.ts @@ -2,6 +2,7 @@ import type { PtySpawnResult } from '../../../providers/pty-provider-contract' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { pendingByPaneKey, rendererSerializerReadiness } from './serializer-state' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { buildFreshShellViewportBlankingSequence } from '../../../../shared/terminal-fresh-shell-viewport' export function seedHeadlessTerminalFromSpawnResult( runtime: OrcaRuntimeService | undefined, @@ -36,7 +37,12 @@ export function seedHeadlessTerminalFromSpawnResult( typeof result.coldRestore.cols === 'number' && typeof result.coldRestore.rows === 'number' ? { cols: result.coldRestore.cols, rows: result.coldRestore.rows } : undefined - runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, size, { + // A fresh shell follows the restored history: park the cursor below it, as the window does, + // or a view painted from this copy (a paired host's desktop, a phone) joins the new prompt. + const seed = `${result.coldRestore.scrollback}${buildFreshShellViewportBlankingSequence( + size?.rows ?? 24 + )}` + runtime.seedHeadlessTerminal(result.id, seed, size, { cwd: result.coldRestore.cwd, oscLinks: result.coldRestore.oscLinks, preferProviderIfExisting: true diff --git a/src/main/ipc/pty/pty-restore-record-seeding.test.ts b/src/main/ipc/pty/pty-restore-record-seeding.test.ts index e7a6e8257e7..24b98ccf373 100644 --- a/src/main/ipc/pty/pty-restore-record-seeding.test.ts +++ b/src/main/ipc/pty/pty-restore-record-seeding.test.ts @@ -20,6 +20,7 @@ import { registerPtyHandlers } from './register-handlers' import { clearProviderPtyState } from './provider/state-cleanup' import { getPtyIdForPaneKey } from './pane/key-state' import { setLocalPtyProvider } from './provider/registry' +import { buildFreshShellViewportBlankingSequence } from '../../../shared/terminal-fresh-shell-viewport' vi.mock('electron', () => import('../pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('../pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -110,10 +111,11 @@ describe('registerPtyHandlers', () => { await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) + // Restored history, then the fresh shell's viewport below it (verify-15b `$ $`). expect(runtime.seedHeadlessTerminal).toHaveBeenNthCalledWith( 1, 'pty-cold-restore', - 'restored history\r\n', + `restored history\r\n${buildFreshShellViewportBlankingSequence(43)}`, { cols: 132, rows: 43 }, { cwd: '/projects/restored', oscLinks, preferProviderIfExisting: true } ) @@ -123,7 +125,7 @@ describe('registerPtyHandlers', () => { expect(runtime.seedHeadlessTerminal).toHaveBeenNthCalledWith( 2, 'pty-legacy-cold-restore', - 'legacy history\r\n', + `legacy history\r\n${buildFreshShellViewportBlankingSequence(24)}`, undefined, { cwd: '/projects/legacy', oscLinks: undefined, preferProviderIfExisting: true } ) diff --git a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts index b3b7c0df756..2e4bf3dc15e 100644 --- a/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts +++ b/src/main/persistence/loading-store/pty-spawn-restore-durability.test.ts @@ -66,8 +66,11 @@ describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => { runtime.onPtyData(binding.ptyId, 'live output\r\n', Date.now()) gate.finish.resolve() await pending - const snapshot = await runtime.serializeMainTerminalBuffer(binding.ptyId) - expect(snapshot?.data).toContain('restored history') + // Cold restore parks restored history above a blank viewport, so read it from scrollback. + const snapshot = await runtime.serializeMainTerminalBuffer(binding.ptyId, { + scrollbackRows: 1_000 + }) + expect(snapshot?.data.split('restored history')).toHaveLength(2) expect(snapshot?.data).toContain('live output') expect(snapshot?.data.indexOf('restored history')).toBeLessThan( snapshot?.data.indexOf('live output') ?? -1