Fix split ANSI leakage in terminal preview (#5478)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
b516108bde
commit
37c7e4bbc5
|
|
@ -6411,6 +6411,140 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(read.latestCursor).toBe('1')
|
||||
})
|
||||
|
||||
it('does not retain split ANSI controls as visible terminal preview text', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
runtime.onPtyData('pty-1', 'Working\r\x1b[', 100)
|
||||
runtime.onPtyData('pty-1', '38;2;190;210;223;49mWo', 101)
|
||||
|
||||
const colorRead = await runtime.readTerminal(terminal.handle)
|
||||
const colorRetained = colorRead.tail.join('\n')
|
||||
expect(colorRetained).toContain('Wo')
|
||||
expect(colorRetained).not.toContain('38;2')
|
||||
expect(colorRetained).not.toContain('49m')
|
||||
|
||||
runtime.onPtyData('pty-1', 'rking\x1b[?2026', 102)
|
||||
runtime.onPtyData('pty-1', 'l', 103)
|
||||
|
||||
const modeRead = await runtime.readTerminal(terminal.handle)
|
||||
const retained = modeRead.tail.join('\n')
|
||||
expect(retained).toContain('Working')
|
||||
expect(retained).not.toContain('38;2')
|
||||
expect(retained).not.toContain('?2026')
|
||||
expect(retained).not.toContain('49m')
|
||||
|
||||
runtime.onPtyData('pty-1', ` done\x1b]0;${'x'.repeat(5000)}`, 104)
|
||||
runtime.onPtyData('pty-1', '\u0007\n', 105)
|
||||
|
||||
const longRead = await runtime.readTerminal(terminal.handle)
|
||||
const longRetained = longRead.tail.join('\n')
|
||||
expect(longRetained).toContain('Working done')
|
||||
expect(longRetained).not.toContain('x'.repeat(100))
|
||||
const pty = (
|
||||
runtime as unknown as {
|
||||
ptysById: Map<string, { lastOscTitle: string | null }>
|
||||
}
|
||||
).ptysById.get('pty-1')
|
||||
expect(pty?.lastOscTitle).toBe('x'.repeat(4092))
|
||||
})
|
||||
|
||||
it('does not retain split ST-terminated string controls as preview text', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
runtime.onPtyData('pty-1', 'Before \x1b_Gi=31337,s=1,', 100)
|
||||
runtime.onPtyData('pty-1', 'v=1,a=q,t=d,f=24;AAAA\x1b\\After\n', 101)
|
||||
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
const retained = read.tail.join('\n')
|
||||
expect(retained).toContain('BeforeAfter')
|
||||
expect(retained).not.toContain('Gi=31337')
|
||||
expect(retained).not.toContain('AAAA')
|
||||
})
|
||||
|
||||
it('preserves non-ASCII terminal preview text in chunks with controls', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
syncSinglePty(runtime)
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
runtime.onPtyData('pty-1', '\x1b[32mHéllo 🌊\x1b[0m\n', 100)
|
||||
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
expect(read.tail).toEqual(['Héllo 🌊'])
|
||||
})
|
||||
|
||||
it('detects split OSC titles before retaining terminal previews', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex work', 100)
|
||||
runtime.onPtyData('pty-1', 'ing\x07Visible\n', 101)
|
||||
|
||||
const pty = (
|
||||
runtime as unknown as {
|
||||
ptysById: Map<string, { lastOscTitle: string | null; lastAgentStatus: string | null }>
|
||||
}
|
||||
).ptysById.get('pty-1')
|
||||
expect(pty?.lastOscTitle).toBe('Codex working')
|
||||
expect(pty?.lastAgentStatus).toBe('working')
|
||||
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
expect(read.tail.join('\n')).toContain('Visible')
|
||||
expect(read.tail.join('\n')).not.toContain('Codex working')
|
||||
})
|
||||
|
||||
it('detects ST-terminated OSC titles split before the final backslash', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x1b', 100)
|
||||
runtime.onPtyData('pty-1', '\\Visible\n', 101)
|
||||
|
||||
const pty = (
|
||||
runtime as unknown as {
|
||||
ptysById: Map<string, { lastOscTitle: string | null; lastAgentStatus: string | null }>
|
||||
}
|
||||
).ptysById.get('pty-1')
|
||||
expect(pty?.lastOscTitle).toBe('Codex working')
|
||||
expect(pty?.lastAgentStatus).toBe('working')
|
||||
})
|
||||
|
||||
it('preserves a trailing escape after a completed OSC title', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07\x1b', 100)
|
||||
runtime.onPtyData('pty-1', ']0;Codex done\x07Visible\n', 101)
|
||||
|
||||
const pty = (
|
||||
runtime as unknown as {
|
||||
ptysById: Map<string, { lastOscTitle: string | null; lastAgentStatus: string | null }>
|
||||
}
|
||||
).ptysById.get('pty-1')
|
||||
expect(pty?.lastOscTitle).toBe('Codex done')
|
||||
expect(pty?.lastAgentStatus).toBe('idle')
|
||||
})
|
||||
|
||||
it('seeds newly synced leaves from PTY pending ANSI state', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.registerPty('pty-1', TEST_WORKTREE_ID)
|
||||
runtime.onPtyData('pty-1', 'Working\r\x1b[', 100)
|
||||
|
||||
syncSinglePty(runtime)
|
||||
const [terminal] = (await runtime.listTerminals()).terminals
|
||||
runtime.onPtyData('pty-1', '38;2;190;210;223;49mDone\n', 101)
|
||||
|
||||
const read = await runtime.readTerminal(terminal.handle)
|
||||
const retained = read.tail.join('\n')
|
||||
expect(retained).toContain('Done')
|
||||
expect(retained).not.toContain('38;2')
|
||||
expect(retained).not.toContain('49m')
|
||||
})
|
||||
|
||||
it('bounds retained partial terminal output before preview reads', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
isClaudeManagementTitle,
|
||||
isShellProcess
|
||||
} from '../../shared/agent-detection'
|
||||
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
|
||||
import type { AgentStatus } from '../../shared/agent-detection'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
|
|
@ -774,6 +775,7 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & {
|
|||
lastExitCode: number | null
|
||||
tailBuffer: string[]
|
||||
tailPartialLine: string
|
||||
tailPendingAnsi: string
|
||||
tailTruncated: boolean
|
||||
tailLinesTotal: number
|
||||
preview: string
|
||||
|
|
@ -834,6 +836,7 @@ type RuntimePtyWorktreeRecord = {
|
|||
lastOutputAt: number | null
|
||||
tailBuffer: string[]
|
||||
tailPartialLine: string
|
||||
tailPendingAnsi: string
|
||||
tailTruncated: boolean
|
||||
tailLinesTotal: number
|
||||
preview: string
|
||||
|
|
@ -1705,6 +1708,9 @@ export class OrcaRuntimeService {
|
|||
string,
|
||||
ReturnType<typeof createAgentStatusOscProcessor>
|
||||
>()
|
||||
// Why: ordinary OSC 0/1/2 titles can split across PTY chunks, especially over
|
||||
// SSH/relay buffering. Keep a small raw scan tail so status titles are not lost.
|
||||
private oscTitleScanTailByPtyId = new Map<string, string>()
|
||||
// Why: latest agent-status payload per pane, retained so worktree.ps can serve
|
||||
// mobile the same inline agent rows the desktop sidebar renders. Cleared on pty
|
||||
// teardown so dead agents don't linger. See RuntimeAgentRowSnapshot.
|
||||
|
|
@ -2412,6 +2418,8 @@ export class OrcaRuntimeService {
|
|||
existing && existing.ptyId !== ptyId
|
||||
? existing.ptyGeneration + 1
|
||||
: (existing?.ptyGeneration ?? 0)
|
||||
const existingPty = ptyId ? this.ptysById.get(ptyId) : undefined
|
||||
const tailSource = existing?.ptyId === ptyId ? existing : existingPty
|
||||
|
||||
nextLeaves.set(leafKey, {
|
||||
...leaf,
|
||||
|
|
@ -2419,16 +2427,17 @@ export class OrcaRuntimeService {
|
|||
ptyGeneration,
|
||||
connected: ptyId !== null,
|
||||
writable: this.graphStatus === 'ready' && ptyId !== null,
|
||||
lastOutputAt: existing?.ptyId === ptyId ? existing.lastOutputAt : null,
|
||||
lastExitCode: existing?.ptyId === ptyId ? existing.lastExitCode : null,
|
||||
tailBuffer: existing?.ptyId === ptyId ? existing.tailBuffer : [],
|
||||
tailPartialLine: existing?.ptyId === ptyId ? existing.tailPartialLine : '',
|
||||
tailTruncated: existing?.ptyId === ptyId ? existing.tailTruncated : false,
|
||||
tailLinesTotal: existing?.ptyId === ptyId ? existing.tailLinesTotal : 0,
|
||||
preview: existing?.ptyId === ptyId ? existing.preview : '',
|
||||
lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null,
|
||||
lastOscTitle: existing?.ptyId === ptyId ? existing.lastOscTitle : null,
|
||||
lastOscTitleAt: existing?.ptyId === ptyId ? existing.lastOscTitleAt : null,
|
||||
lastOutputAt: tailSource?.lastOutputAt ?? null,
|
||||
lastExitCode: tailSource?.lastExitCode ?? null,
|
||||
tailBuffer: tailSource?.tailBuffer ?? [],
|
||||
tailPartialLine: tailSource?.tailPartialLine ?? '',
|
||||
tailPendingAnsi: tailSource?.tailPendingAnsi ?? '',
|
||||
tailTruncated: tailSource?.tailTruncated ?? false,
|
||||
tailLinesTotal: tailSource?.tailLinesTotal ?? 0,
|
||||
preview: tailSource?.preview ?? '',
|
||||
lastAgentStatus: tailSource?.lastAgentStatus ?? null,
|
||||
lastOscTitle: tailSource?.lastOscTitle ?? null,
|
||||
lastOscTitleAt: tailSource?.lastOscTitleAt ?? null,
|
||||
paneTitleUpdatedAt:
|
||||
existing?.ptyId === ptyId && existing.paneTitle === leaf.paneTitle
|
||||
? existing.paneTitleUpdatedAt
|
||||
|
|
@ -3746,20 +3755,16 @@ export class OrcaRuntimeService {
|
|||
// strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.)
|
||||
// announce status via OSC 0/1/2 title sequences — this is the same
|
||||
// detection path the renderer uses for notifications and sidebar badges.
|
||||
const oscTitle = extractLastOscTitle(data)
|
||||
const oscTitle = this.extractLastOscTitleForPty(ptyId, data)
|
||||
const agentStatus = oscTitle ? detectAgentStatusFromTitle(oscTitle) : null
|
||||
|
||||
let normalizedData: string | null = null
|
||||
const getNormalizedData = (): string => {
|
||||
normalizedData ??= normalizeTerminalChunk(data)
|
||||
return normalizedData
|
||||
}
|
||||
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
|
||||
let shouldTouchPtyBackedSessionTabs = false
|
||||
const ptyTailBefore = pty
|
||||
? {
|
||||
lines: pty.tailBuffer,
|
||||
partialLine: pty.tailPartialLine,
|
||||
pendingAnsi: pty.tailPendingAnsi,
|
||||
truncated: pty.tailTruncated,
|
||||
linesTotal: pty.tailLinesTotal
|
||||
}
|
||||
|
|
@ -3769,10 +3774,12 @@ export class OrcaRuntimeService {
|
|||
pty.connected = true
|
||||
pty.disconnectedAt = null
|
||||
pty.lastOutputAt = at
|
||||
const normalized = normalizeTerminalChunk(data, pty.tailPendingAnsi)
|
||||
pty.tailPendingAnsi = normalized.pendingAnsi
|
||||
const nextTail = appendNormalizedToTailBuffer(
|
||||
pty.tailBuffer,
|
||||
pty.tailPartialLine,
|
||||
getNormalizedData()
|
||||
normalized.text
|
||||
)
|
||||
ptyTailAfter = nextTail
|
||||
pty.tailBuffer = nextTail.lines
|
||||
|
|
@ -3814,6 +3821,7 @@ export class OrcaRuntimeService {
|
|||
tailStateMatches(
|
||||
leaf.tailBuffer,
|
||||
leaf.tailPartialLine,
|
||||
leaf.tailPendingAnsi,
|
||||
leaf.tailTruncated,
|
||||
leaf.tailLinesTotal,
|
||||
ptyTailBefore
|
||||
|
|
@ -3823,14 +3831,17 @@ export class OrcaRuntimeService {
|
|||
// the PTY tail update instead of splitting large output twice.
|
||||
leaf.tailBuffer = pty.tailBuffer
|
||||
leaf.tailPartialLine = pty.tailPartialLine
|
||||
leaf.tailPendingAnsi = pty.tailPendingAnsi
|
||||
leaf.tailTruncated = pty.tailTruncated
|
||||
leaf.tailLinesTotal = pty.tailLinesTotal
|
||||
leaf.preview = pty.preview
|
||||
} else {
|
||||
const normalized = normalizeTerminalChunk(data, leaf.tailPendingAnsi)
|
||||
leaf.tailPendingAnsi = normalized.pendingAnsi
|
||||
const nextTail = appendNormalizedToTailBuffer(
|
||||
leaf.tailBuffer,
|
||||
leaf.tailPartialLine,
|
||||
getNormalizedData()
|
||||
normalized.text
|
||||
)
|
||||
leaf.tailBuffer = nextTail.lines
|
||||
leaf.tailPartialLine = nextTail.partialLine
|
||||
|
|
@ -3893,6 +3904,21 @@ export class OrcaRuntimeService {
|
|||
return processor(data)
|
||||
}
|
||||
|
||||
private extractLastOscTitleForPty(ptyId: string, data: string): string | null {
|
||||
const previousTail = this.oscTitleScanTailByPtyId.get(ptyId)
|
||||
if (!previousTail && !data.includes('\x1b')) {
|
||||
return null
|
||||
}
|
||||
const input = `${previousTail ?? ''}${data}`
|
||||
const scanTail = extractOscTitleScanTail(input)
|
||||
if (scanTail.length > 0) {
|
||||
this.oscTitleScanTailByPtyId.set(ptyId, scanTail)
|
||||
} else {
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
}
|
||||
return extractLastOscTitle(input)
|
||||
}
|
||||
|
||||
private emitTerminalAgentStatusEvents(ptyId: string, chunk: ProcessedAgentStatusChunk): void {
|
||||
// Why: snapshot retention (for mobile worktree.ps) must run even when no
|
||||
// renderer listener is attached, so we don't early-return on a missing
|
||||
|
|
@ -5193,6 +5219,7 @@ export class OrcaRuntimeService {
|
|||
this.recentPtyOutputById.delete(ptyId)
|
||||
this.ptyOutputSequenceById.delete(ptyId)
|
||||
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
this.clearAgentRowSnapshotsForPty(ptyId)
|
||||
// Layout state machine: clear `layouts` and `layoutQueues`. Any
|
||||
// already-queued applyLayout work for this ptyId will run, but every
|
||||
|
|
@ -14183,6 +14210,7 @@ export class OrcaRuntimeService {
|
|||
lastOutputAt: state.lastOutputAt ?? null,
|
||||
tailBuffer: [],
|
||||
tailPartialLine: '',
|
||||
tailPendingAnsi: '',
|
||||
tailTruncated: false,
|
||||
tailLinesTotal: 0,
|
||||
preview: state.preview ?? ''
|
||||
|
|
@ -14301,6 +14329,7 @@ export class OrcaRuntimeService {
|
|||
// but their retained transcripts must not accumulate after the process dies.
|
||||
pty.tailBuffer = []
|
||||
pty.tailPartialLine = ''
|
||||
pty.tailPendingAnsi = ''
|
||||
pty.tailTruncated = false
|
||||
pty.tailLinesTotal = 0
|
||||
}
|
||||
|
|
@ -14324,6 +14353,7 @@ export class OrcaRuntimeService {
|
|||
this.recentPtyOutputById.delete(ptyId)
|
||||
this.ptyOutputSequenceById.delete(ptyId)
|
||||
this.agentStatusOscProcessorsByPtyId.delete(ptyId)
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
this.clearAgentRowSnapshotsForPty(ptyId)
|
||||
const handle = this.handleByPtyId.get(ptyId)
|
||||
if (handle) {
|
||||
|
|
@ -18574,6 +18604,7 @@ export class OrcaRuntimeService {
|
|||
const MAX_TAIL_LINES = 2000
|
||||
const MAX_TAIL_CHARS = 256 * 1024
|
||||
const MAX_TAIL_PARTIAL_CHARS = 4000
|
||||
const MAX_TAIL_PENDING_ANSI_CHARS = 4096
|
||||
const DEFAULT_TERMINAL_READ_LIMIT = 120
|
||||
const MAX_TERMINAL_READ_LIMIT = 2000
|
||||
const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024
|
||||
|
|
@ -18861,23 +18892,38 @@ function parseAnsiControlSequence(
|
|||
}
|
||||
return null
|
||||
}
|
||||
if (isStTerminatedStringControlIntroducer(introducer)) {
|
||||
for (let index = escapeIndex + 2; index < value.length; index += 1) {
|
||||
if (value[index] === '\u001b' && value[index + 1] === '\\') {
|
||||
return { kind: 'other', endIndex: index + 1 }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return { kind: 'other', endIndex: escapeIndex + 1 }
|
||||
}
|
||||
|
||||
function isStTerminatedStringControlIntroducer(introducer: string | undefined): boolean {
|
||||
return introducer === 'P' || introducer === 'X' || introducer === '^' || introducer === '_'
|
||||
}
|
||||
|
||||
function tailStateMatches(
|
||||
lines: string[],
|
||||
partialLine: string,
|
||||
pendingAnsi: string,
|
||||
truncated: boolean,
|
||||
linesTotal: number,
|
||||
snapshot: {
|
||||
lines: string[]
|
||||
partialLine: string
|
||||
pendingAnsi: string
|
||||
truncated: boolean
|
||||
linesTotal: number
|
||||
}
|
||||
): boolean {
|
||||
if (
|
||||
partialLine !== snapshot.partialLine ||
|
||||
pendingAnsi !== snapshot.pendingAnsi ||
|
||||
truncated !== snapshot.truncated ||
|
||||
linesTotal !== snapshot.linesTotal ||
|
||||
lines.length !== snapshot.lines.length
|
||||
|
|
@ -19475,18 +19521,50 @@ function mergeWorktreeStatus(
|
|||
return WORKTREE_STATUS_PRIORITY[next] > WORKTREE_STATUS_PRIORITY[current] ? next : current
|
||||
}
|
||||
|
||||
function normalizeTerminalChunk(chunk: string): string {
|
||||
function normalizeTerminalChunk(
|
||||
chunk: string,
|
||||
pendingAnsi: string = ''
|
||||
): { text: string; pendingAnsi: string } {
|
||||
// Why: most high-throughput PTY chunks are plain printable text. Avoid
|
||||
// running every ANSI/OSC regex over megabytes that do not need normalization.
|
||||
if (!terminalChunkNeedsNormalization(chunk)) {
|
||||
return chunk
|
||||
if (pendingAnsi.length === 0 && !terminalChunkNeedsNormalization(chunk)) {
|
||||
return { text: chunk, pendingAnsi: '' }
|
||||
}
|
||||
return chunk
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
||||
.replace(/\x1b[@-_]/g, '')
|
||||
.replace(/[^\x08\x09\x0a\x0d\x20-\x7e]/g, '')
|
||||
const combined = `${pendingAnsi}${chunk}`
|
||||
let text = ''
|
||||
for (let index = 0; index < combined.length; index += 1) {
|
||||
const char = combined[index]
|
||||
if (char === '\x1b') {
|
||||
if (index + 1 >= combined.length) {
|
||||
return { text, pendingAnsi: combined.slice(index) }
|
||||
}
|
||||
const parsed = parseAnsiControlSequence(combined, index)
|
||||
if (!parsed) {
|
||||
return {
|
||||
text,
|
||||
pendingAnsi: trimPendingAnsiControl(combined.slice(index))
|
||||
}
|
||||
}
|
||||
index = parsed.endIndex
|
||||
continue
|
||||
}
|
||||
if (char === '\r' && combined[index + 1] === '\n') {
|
||||
text += '\n'
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
const code = combined.charCodeAt(index)
|
||||
if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0d) {
|
||||
text += char
|
||||
} else if (isTerminalPreviewPrintableCodeUnit(code)) {
|
||||
text += char
|
||||
}
|
||||
}
|
||||
return { text, pendingAnsi: '' }
|
||||
}
|
||||
|
||||
function isTerminalPreviewPrintableCodeUnit(code: number): boolean {
|
||||
return code >= 0x20 && code !== 0x7f && (code < 0x80 || code > 0x9f)
|
||||
}
|
||||
|
||||
function terminalChunkNeedsNormalization(chunk: string): boolean {
|
||||
|
|
@ -19494,10 +19572,11 @@ function terminalChunkNeedsNormalization(chunk: string): boolean {
|
|||
const code = chunk.charCodeAt(index)
|
||||
if (
|
||||
code === 0x1b ||
|
||||
code === 0x7f ||
|
||||
code === 0x0d ||
|
||||
code < 0x09 ||
|
||||
(code > 0x0a && code < 0x20) ||
|
||||
code > 0x7e
|
||||
(code >= 0x80 && code <= 0x9f)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -19505,6 +19584,15 @@ function terminalChunkNeedsNormalization(chunk: string): boolean {
|
|||
return false
|
||||
}
|
||||
|
||||
function trimPendingAnsiControl(value: string): string {
|
||||
if (value.length <= MAX_TAIL_PENDING_ANSI_CHARS) {
|
||||
return value
|
||||
}
|
||||
const introducer = value.slice(0, Math.min(2, value.length))
|
||||
const suffixBudget = Math.max(0, MAX_TAIL_PENDING_ANSI_CHARS - introducer.length)
|
||||
return `${introducer}${value.slice(-suffixBudget)}`
|
||||
}
|
||||
|
||||
function maxTimestamp(left: number | null, right: number | null): number | null {
|
||||
if (left === null) {
|
||||
return right
|
||||
|
|
|
|||
|
|
@ -84,6 +84,104 @@ describe('AgentDetector', () => {
|
|||
expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120)
|
||||
})
|
||||
|
||||
it('records lifecycle transitions from split OSC titles', () => {
|
||||
const stats = {
|
||||
onAgentStart: vi.fn(),
|
||||
onAgentStop: vi.fn()
|
||||
}
|
||||
const detector = new AgentDetector(stats as never)
|
||||
|
||||
detector.onData('pty-1', '\x1b]0;Codex work', 100)
|
||||
detector.onData('pty-1', 'ing\x07', 101)
|
||||
detector.onData('pty-1', 'meaningful output', 120)
|
||||
detector.onData('pty-1', '\x1b]0;Codex do', 140)
|
||||
detector.onData('pty-1', 'ne\x07', 141)
|
||||
|
||||
expect(stats.onAgentStart).toHaveBeenCalledTimes(1)
|
||||
expect(stats.onAgentStart).toHaveBeenCalledWith('pty-1', 101)
|
||||
expect(stats.onAgentStop).toHaveBeenCalledTimes(1)
|
||||
expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120)
|
||||
})
|
||||
|
||||
it('does not treat an ST-split OSC title as meaningful output', () => {
|
||||
const stats = {
|
||||
onAgentStart: vi.fn(),
|
||||
onAgentStop: vi.fn()
|
||||
}
|
||||
const detector = new AgentDetector(stats as never)
|
||||
|
||||
detector.onData('pty-1', oscTitle('Codex working'), 100)
|
||||
detector.onData('pty-1', 'real output', 120)
|
||||
detector.onData('pty-1', '\x1b]0;Codex done\x1b', 140)
|
||||
detector.onData('pty-1', '\\', 141)
|
||||
|
||||
expect(stats.onAgentStop).toHaveBeenCalledTimes(1)
|
||||
expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120)
|
||||
})
|
||||
|
||||
it('does not treat split ST-terminated string controls as meaningful output', () => {
|
||||
const stats = {
|
||||
onAgentStart: vi.fn(),
|
||||
onAgentStop: vi.fn()
|
||||
}
|
||||
const detector = new AgentDetector(stats as never)
|
||||
|
||||
detector.onData('pty-1', oscTitle('Codex working'), 100)
|
||||
detector.onData('pty-1', 'real output', 120)
|
||||
detector.onData('pty-1', '\x1b_Gi=31337,s=1,', 140)
|
||||
detector.onData('pty-1', 'v=1,a=q,t=d,f=24;AAAA\x1b\\', 141)
|
||||
detector.onData('pty-1', oscTitle('Codex done'), 160)
|
||||
|
||||
expect(stats.onAgentStop).toHaveBeenCalledTimes(1)
|
||||
expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120)
|
||||
})
|
||||
|
||||
it('treats non-ASCII output in escaped chunks as meaningful', () => {
|
||||
const stats = {
|
||||
onAgentStart: vi.fn(),
|
||||
onAgentStop: vi.fn()
|
||||
}
|
||||
const detector = new AgentDetector(stats as never)
|
||||
|
||||
detector.onData('pty-1', oscTitle('Codex working'), 100)
|
||||
detector.onData('pty-1', '\x1b[32m修正中 🌊\x1b[0m', 120)
|
||||
detector.onData('pty-1', oscTitle('Codex done'), 140)
|
||||
|
||||
expect(stats.onAgentStop).toHaveBeenCalledTimes(1)
|
||||
expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120)
|
||||
})
|
||||
|
||||
it('keeps capped split OSC title tails from becoming meaningful output', () => {
|
||||
const stats = {
|
||||
onAgentStart: vi.fn(),
|
||||
onAgentStop: vi.fn()
|
||||
}
|
||||
const detector = new AgentDetector(stats as never)
|
||||
|
||||
detector.onData('pty-1', oscTitle('Codex working'), 100)
|
||||
detector.onData('pty-1', 'real output', 120)
|
||||
detector.onData('pty-1', `\x1b]0;${'x'.repeat(5000)}`, 140)
|
||||
detector.onData('pty-1', ' Codex done\x07', 141)
|
||||
|
||||
expect(stats.onAgentStop).toHaveBeenCalledTimes(1)
|
||||
expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120)
|
||||
})
|
||||
|
||||
it('preserves a trailing escape after a completed OSC title for stats detection', () => {
|
||||
const stats = {
|
||||
onAgentStart: vi.fn(),
|
||||
onAgentStop: vi.fn()
|
||||
}
|
||||
const detector = new AgentDetector(stats as never)
|
||||
|
||||
detector.onData('pty-1', '\x1b]0;bash\x07\x1b', 100)
|
||||
detector.onData('pty-1', ']0;Codex working\x07', 101)
|
||||
|
||||
expect(stats.onAgentStart).toHaveBeenCalledTimes(1)
|
||||
expect(stats.onAgentStart).toHaveBeenCalledWith('pty-1', 101)
|
||||
expect(stats.onAgentStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops an active session on PTY exit', () => {
|
||||
const stats = {
|
||||
onAgentStart: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { extractLastOscTitle, detectAgentStatusFromTitle } from '../../shared/agent-detection'
|
||||
import type { AgentStatus } from '../../shared/agent-detection'
|
||||
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
|
||||
import type { StatsCollector } from './collector'
|
||||
|
||||
type PtyAgentState = 'unknown' | 'agent' | 'stopped'
|
||||
|
|
@ -20,6 +21,8 @@ type PtyRecord = {
|
|||
|
||||
type MeaningfulContentDetector = (chunk: string) => boolean
|
||||
|
||||
const MEANINGFUL_CONTENT_SCAN_TAIL_LIMIT = 4096
|
||||
|
||||
/**
|
||||
* Lightweight normalization to detect whether a PTY data chunk contains
|
||||
* meaningful (non-ANSI, non-OSC) output. Mirrors the regex passes in
|
||||
|
|
@ -30,7 +33,13 @@ function hasMeaningfulContent(chunk: string): boolean {
|
|||
// chain just to prove they contain visible output.
|
||||
for (let index = 0; index < chunk.length; index++) {
|
||||
const code = chunk.charCodeAt(index)
|
||||
if (code === 0x1b || code < 0x09 || (code > 0x0d && code < 0x20) || code > 0x7e) {
|
||||
if (
|
||||
code === 0x1b ||
|
||||
code === 0x7f ||
|
||||
code < 0x09 ||
|
||||
(code > 0x0d && code < 0x20) ||
|
||||
(code >= 0x80 && code <= 0x9f)
|
||||
) {
|
||||
break
|
||||
}
|
||||
if (code > 0x20) {
|
||||
|
|
@ -44,13 +53,19 @@ function hasMeaningfulContent(chunk: string): boolean {
|
|||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\][^\x07]*(?:\x1b)?$/g, '') // incomplete OSC tail
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, '') // ST-terminated string controls
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b[PX^_][\s\S]*(?:\x1b)?$/g, '') // incomplete string-control tail
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') // CSI sequences
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\x1b[@-_]/g, '') // Fe sequences
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/\u0008/g, '') // backspace
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[^\x09\x0a\x20-\x7e]/g, '') // non-printable
|
||||
.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '') // non-printable
|
||||
.trim()
|
||||
return stripped.length > 0
|
||||
}
|
||||
|
|
@ -73,6 +88,8 @@ function hasMeaningfulContent(chunk: string): boolean {
|
|||
*/
|
||||
export class AgentDetector {
|
||||
private ptys = new Map<string, PtyRecord>()
|
||||
private oscTitleScanTailByPtyId = new Map<string, string>()
|
||||
private meaningfulContentScanTailByPtyId = new Map<string, string>()
|
||||
private stats: StatsCollector
|
||||
private meaningfulContentDetector: MeaningfulContentDetector
|
||||
|
||||
|
|
@ -104,8 +121,13 @@ export class AgentDetector {
|
|||
}
|
||||
|
||||
let hasMeaningfulOutput: boolean | null = null
|
||||
const previousMeaningfulTail = this.meaningfulContentScanTailByPtyId.get(ptyId)
|
||||
const meaningfulData = previousMeaningfulTail ? `${previousMeaningfulTail}${rawData}` : rawData
|
||||
const getHasMeaningfulOutput = (): boolean => {
|
||||
hasMeaningfulOutput ??= this.meaningfulContentDetector(rawData)
|
||||
if (hasMeaningfulOutput === null) {
|
||||
hasMeaningfulOutput = this.meaningfulContentDetector(meaningfulData)
|
||||
this.updateMeaningfulContentScanTail(ptyId, meaningfulData)
|
||||
}
|
||||
return hasMeaningfulOutput
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +135,7 @@ export class AgentDetector {
|
|||
record.lastMeaningfulOutputAt = at
|
||||
}
|
||||
|
||||
const title = extractLastOscTitle(rawData)
|
||||
const title = this.extractLastOscTitleForPty(ptyId, rawData)
|
||||
if (title === null) {
|
||||
return
|
||||
}
|
||||
|
|
@ -173,5 +195,89 @@ export class AgentDetector {
|
|||
|
||||
record.state = 'stopped'
|
||||
this.ptys.delete(ptyId)
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
this.meaningfulContentScanTailByPtyId.delete(ptyId)
|
||||
}
|
||||
|
||||
private extractLastOscTitleForPty(ptyId: string, rawData: string): string | null {
|
||||
const previousTail = this.oscTitleScanTailByPtyId.get(ptyId)
|
||||
if (!previousTail && !rawData.includes('\x1b')) {
|
||||
return null
|
||||
}
|
||||
const input = `${previousTail ?? ''}${rawData}`
|
||||
const scanTail = extractOscTitleScanTail(input)
|
||||
if (scanTail.length > 0) {
|
||||
this.oscTitleScanTailByPtyId.set(ptyId, scanTail)
|
||||
} else {
|
||||
this.oscTitleScanTailByPtyId.delete(ptyId)
|
||||
}
|
||||
return extractLastOscTitle(input)
|
||||
}
|
||||
|
||||
private updateMeaningfulContentScanTail(ptyId: string, rawData: string): void {
|
||||
const tail = extractMeaningfulContentScanTail(rawData)
|
||||
if (tail.length > 0) {
|
||||
this.meaningfulContentScanTailByPtyId.set(ptyId, tail)
|
||||
} else {
|
||||
this.meaningfulContentScanTailByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractMeaningfulContentScanTail(value: string): string {
|
||||
const escapeIndex = value.lastIndexOf('\x1b')
|
||||
if (escapeIndex === -1) {
|
||||
return ''
|
||||
}
|
||||
const parsed = parseMeaningfulControlSequence(value, escapeIndex)
|
||||
return parsed === null ? trimMeaningfulContentScanTail(value.slice(escapeIndex)) : ''
|
||||
}
|
||||
|
||||
function parseMeaningfulControlSequence(value: string, escapeIndex: number): number | null {
|
||||
const introducer = value[escapeIndex + 1]
|
||||
if (!introducer) {
|
||||
return null
|
||||
}
|
||||
if (introducer === '[') {
|
||||
for (let index = escapeIndex + 2; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (introducer === ']') {
|
||||
for (let index = escapeIndex + 2; index < value.length; index += 1) {
|
||||
if (value[index] === '\u0007') {
|
||||
return index
|
||||
}
|
||||
if (value[index] === '\u001b' && value[index + 1] === '\\') {
|
||||
return index + 1
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (isStTerminatedStringControlIntroducer(introducer)) {
|
||||
for (let index = escapeIndex + 2; index < value.length; index += 1) {
|
||||
if (value[index] === '\u001b' && value[index + 1] === '\\') {
|
||||
return index + 1
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return escapeIndex + 1
|
||||
}
|
||||
|
||||
function isStTerminatedStringControlIntroducer(introducer: string): boolean {
|
||||
return introducer === 'P' || introducer === 'X' || introducer === '^' || introducer === '_'
|
||||
}
|
||||
|
||||
function trimMeaningfulContentScanTail(value: string): string {
|
||||
if (value.length <= MEANINGFUL_CONTENT_SCAN_TAIL_LIMIT) {
|
||||
return value
|
||||
}
|
||||
const introducer = value.slice(0, Math.min(2, value.length))
|
||||
const suffixBudget = Math.max(0, MEANINGFUL_CONTENT_SCAN_TAIL_LIMIT - introducer.length)
|
||||
return `${introducer}${value.slice(-suffixBudget)}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
const OSC_TITLE_SCAN_TAIL_LIMIT = 4096
|
||||
const OSC_TITLE_PREFIX_LENGTH = 4
|
||||
|
||||
export function extractOscTitleScanTail(input: string): string {
|
||||
const lastOsc = input.lastIndexOf('\x1b]')
|
||||
if (lastOsc !== -1) {
|
||||
const suffix = input.slice(lastOsc)
|
||||
if (!suffix.includes('\x07') && !suffix.includes('\x1b\\')) {
|
||||
return trimOscTitleScanTail(suffix)
|
||||
}
|
||||
return input.endsWith('\x1b') ? '\x1b' : ''
|
||||
}
|
||||
return input.endsWith('\x1b') ? '\x1b' : ''
|
||||
}
|
||||
|
||||
function trimOscTitleScanTail(value: string): string {
|
||||
if (value.length <= OSC_TITLE_SCAN_TAIL_LIMIT) {
|
||||
return value
|
||||
}
|
||||
// Preserve the OSC introducer while keeping the newest payload bytes, so
|
||||
// bounded tails can still reconstruct a split title terminator.
|
||||
const prefix = value.slice(0, Math.min(OSC_TITLE_PREFIX_LENGTH, value.length))
|
||||
const suffixBudget = Math.max(0, OSC_TITLE_SCAN_TAIL_LIMIT - prefix.length)
|
||||
return `${prefix}${value.slice(-suffixBudget)}`
|
||||
}
|
||||
Loading…
Reference in New Issue