fix(daemon): OMP's prompt-bubble markers no longer trip the dead-app repair check (#10923) (#30285)

This commit is contained in:
Neil
2026-10-18 02:18:59 -07:00
committed by GitHub
parent ddf4e6bdf2
commit 3501ea935d
3 changed files with 117 additions and 5 deletions
@@ -0,0 +1,89 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner'
import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier'
// Real OMP 18.10.3 PTY bytes for one prompt: each user bubble ends with `B C D;0` (#10923).
const OMP_TRANSCRIPT = readFileSync(
join(__dirname, '../runtime/__fixtures__/omp-user-bubble-osc133-zone.txt'),
'utf8'
)
const A = '\x1b]133;A\x07'
const B = '\x1b]133;B\x07'
const C = '\x1b]133;C\x07'
const D0 = '\x1b]133;D;0\x07'
// The shell's prompt and the typed command that launched OMP.
const SHELL_LAUNCH = `${A}$ ${B}omp\r\n${C}`
// OMP arms modifyOtherKeys, an input mode a command must disarm before its `D`.
const ARM_MODIFY_OTHER_KEYS = '\x1b[>4;2m'
function triggers(chunks: string[]): number[] {
const scanner = new TerminalShellLifecycleScanner()
const ends: number[] = []
let offset = 0
for (const chunk of chunks) {
let rest = chunk
let restOffset = offset
while (rest.length > 0) {
const events = scanner.scan(rest)
if (events.uncleanDeathTriggerEnd === undefined) {
break
}
ends.push(restOffset + events.uncleanDeathTriggerEnd)
restOffset += events.uncleanDeathTriggerEnd
rest = rest.slice(events.uncleanDeathTriggerEnd)
}
offset += chunk.length
}
return ends
}
function splitEvery(text: string, size: number): string[] {
const chunks: string[] = []
for (let index = 0; index < text.length; index += size) {
chunks.push(text.slice(index, index + size))
}
return chunks
}
describe('an OSC 133 zone a running app closes for itself', () => {
it.each([1, 3, 7, 64, SHELL_LAUNCH.length + OMP_TRANSCRIPT.length])(
"does not read OMP's bubble markers as its death, in %i-char chunks",
(size) => {
expect(triggers(splitEvery(SHELL_LAUNCH + OMP_TRANSCRIPT, size))).toEqual([])
}
)
it('still repairs after the app dies with its input mode armed', () => {
const stream = `${SHELL_LAUNCH}${ARM_MODIFY_OTHER_KEYS}${OMP_TRANSCRIPT}Killed\r\n${D0}`
expect(triggers([stream])).toEqual([stream.length])
})
it.each([
['output separates the C from the D', `${B}${C}x${D0}`],
['the typed command separates the B from the C', `${B}ls\r\n${C}${D0}`],
['the D reports a failure', `${B}${C}\x1b]133;D;1\x07`]
])('still reads a D as the end of the command when %s', (_label, zone) => {
const stream = `${SHELL_LAUNCH}${ARM_MODIFY_OTHER_KEYS}${zone}`
expect(triggers([stream])).toEqual([stream.length])
})
it('passes OMP output through the recovery barrier without holding it for a process probe', () => {
const released: string[] = []
const confirmShellForeground = vi.fn(async () => false)
const barrier = new TerminalShellRecoveryBarrier({
confirmShellForeground,
release: (emission) => released.push(emission.data),
isAlive: () => true
})
let seq = 0
for (const data of splitEvery(SHELL_LAUNCH + OMP_TRANSCRIPT, 4096)) {
barrier.accept({ data, rawStartSeq: seq, rawEndSeq: seq + data.length, transformed: false })
seq += data.length
}
expect(confirmShellForeground).not.toHaveBeenCalled()
expect(released.join('')).toBe(SHELL_LAUNCH + OMP_TRANSCRIPT)
barrier.dispose()
})
})
@@ -1,4 +1,5 @@
import { buildProcessBoundaryGround } from '../../shared/terminal-mode-reset-profiles'
import { closesAppOsc133Zone } from '../../shared/terminal-osc133-command-finished'
import type { TerminalOwner } from '../../shared/terminal-owner'
import { TerminalArmedInputModes } from './terminal-armed-input-modes'
@@ -60,6 +61,8 @@ export class TerminalShellLifecycleScanner {
private generationState = 0
private altActive = false
private commandEnteredAlternateScreen = false
// OSC 133 markers seen back to back since the last other output.
private adjacentMarkers = ''
private readonly inputModes = new TerminalArmedInputModes()
get owner(): TerminalOwner | undefined {
@@ -109,6 +112,9 @@ export class TerminalShellLifecycleScanner {
// with no escape introducer must not pay the regex pass. Split sequences
// stay correct because a partial one always left a non-empty scanTail.
if (this.scanTail.length === 0 && !chunk.includes('\x1b') && !chunk.includes('\x9b')) {
if (chunk.length > 0) {
this.adjacentMarkers = ''
}
return events
}
const previousTailLength = this.scanTail.length
@@ -117,8 +123,13 @@ export class TerminalShellLifecycleScanner {
// The shared regex is safe because scan() is synchronous and never re-enters.
SEQUENCE_RE.lastIndex = 0
let match: RegExpExecArray | null
let outputEnd = 0
while ((match = SEQUENCE_RE.exec(input)) !== null) {
const oscPayload = match[1]
const markersBefore = match.index > outputEnd ? '' : this.adjacentMarkers
outputEnd = match.index + match[0].length
this.adjacentMarkers =
oscPayload === undefined ? '' : (markersBefore + oscPayload.slice(0, 1)).slice(-2)
if (match[0] === '\x1bc') {
this.revoke()
this.inputModes.reset()
@@ -137,7 +148,8 @@ export class TerminalShellLifecycleScanner {
if (marker === 'A') {
this.inputModes.markPrompt()
}
if (marker !== 'D') {
// Why: an app closing its own zone is still running, so its `D` proves no exit (#10923).
if (marker !== 'D' || closesAppOsc133Zone(markersBefore, oscPayload)) {
continue
}
// An alternate screen or a command's input mode still up at command-finished
@@ -208,6 +220,9 @@ export class TerminalShellLifecycleScanner {
}
}
}
if (input.length - this.scanTail.length > outputEnd) {
this.adjacentMarkers = ''
}
return events
}
+12 -4
View File
@@ -50,6 +50,16 @@ function findPrefixCarry(data: string): string {
return ''
}
/**
* TUIs such as OMP end each prompt bubble with `B C D;0`, closing their own zone while they keep
* running (#10923). A shell's `C` follows the typed command line, never its own prompt's `B`
* directly. `adjacentMarkers` holds the markers seen back to back before this one, e.g. 'BC'.
*/
export function closesAppOsc133Zone(adjacentMarkers: string, payload: string): boolean {
const [sequence, exitCode] = payload.split(';')
return sequence === 'D' && exitCode === '0' && adjacentMarkers.endsWith('BC')
}
export type Osc133CommandFinishedScanner = {
/** Feed one raw PTY chunk; fires once per complete OSC 133;D sequence. */
scan: (data: string) => void
@@ -76,10 +86,8 @@ export function createOsc133CommandFinishedScanner(
const handleOsc133 = (payload: string, endInChunk: number): void => {
const [sequence, exitCode] = payload.split(';')
// Why: TUIs such as OMP end each prompt bubble with `B C D;0`, closing their own zone while
// they keep running (#10923). Read as the command's exit, that `D` retired a live agent's
// status. A shell's `C` follows the typed command line, never its own prompt's `B` directly.
const closesAppZone = sequence === 'D' && exitCode === '0' && adjacentMarkers.endsWith('BC')
// Why: read as the command's exit, that `D` retired a live agent's status.
const closesAppZone = closesAppOsc133Zone(adjacentMarkers, payload)
adjacentMarkers = (adjacentMarkers + sequence.slice(0, 1)).slice(-2)
if (closesAppZone) {
return