mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
184 lines
5.8 KiB
TypeScript
184 lines
5.8 KiB
TypeScript
/** Streaming terminal-control sanitizer for the line-oriented first release. */
|
|
|
|
import { Buffer } from 'node:buffer'
|
|
|
|
/** OSC marker emitted by the controlled bash before each prompt. */
|
|
export const PROMPT_MARKER_PREFIX = '133;D;'
|
|
|
|
/** One sanitized chunk plus whether it contained the owned prompt marker. */
|
|
export interface SanitizedChunk {
|
|
text: string
|
|
prompt: boolean
|
|
/** Present when printable text followed the latest owned prompt marker. */
|
|
promptText?: true
|
|
}
|
|
|
|
/**
|
|
* Remove CSI/OSC/short escape sequences while preserving split-sequence carry.
|
|
* Full terminal emulation is deliberately deferred; ordinary line output and
|
|
* the private prompt marker are the supported contract.
|
|
*/
|
|
export class TerminalSanitizer {
|
|
private pending = ''
|
|
private discardMode: 'osc' | 'csi' | undefined
|
|
private discardOscEscape = false
|
|
private trailingCarriageReturn = false
|
|
private awaitingPromptText = false
|
|
|
|
constructor(private readonly maxPendingBytes: number) {}
|
|
|
|
/**
|
|
* Consume one decoded `node-pty` data chunk.
|
|
* @param chunk - decoded terminal data.
|
|
* @returns Printable text and whether the private prompt marker completed.
|
|
*/
|
|
push(chunk: string): SanitizedChunk {
|
|
this.pending += this.discardPrefix(chunk)
|
|
let text = ''
|
|
let prompt = false
|
|
let promptText = false
|
|
let index = 0
|
|
const appendText = (value: string): boolean => {
|
|
text += value
|
|
if (this.awaitingPromptText && value.replace(/[\r\n\x07]/g, '').length > 0) {
|
|
this.awaitingPromptText = false
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
while (index < this.pending.length) {
|
|
const escape = this.pending.indexOf('\x1b', index)
|
|
if (escape < 0) {
|
|
promptText = appendText(this.pending.slice(index)) || promptText
|
|
index = this.pending.length
|
|
break
|
|
}
|
|
promptText = appendText(this.pending.slice(index, escape)) || promptText
|
|
if (escape + 1 >= this.pending.length) {
|
|
index = escape
|
|
break
|
|
}
|
|
const kind = this.pending[escape + 1]
|
|
if (kind === ']') {
|
|
const bel = this.pending.indexOf('\x07', escape + 2)
|
|
const stringTerminator = this.pending.indexOf('\x1b\\', escape + 2)
|
|
let end = -1
|
|
if (bel >= 0 && stringTerminator >= 0) end = Math.min(bel + 1, stringTerminator + 2)
|
|
else if (bel >= 0) end = bel + 1
|
|
else if (stringTerminator >= 0) end = stringTerminator + 2
|
|
if (end < 0) {
|
|
index = escape
|
|
break
|
|
}
|
|
const terminatorBytes = this.pending[end - 1] === '\x07' ? 1 : 2
|
|
const content = this.pending.slice(escape + 2, end - terminatorBytes)
|
|
if (content.startsWith(PROMPT_MARKER_PREFIX)) {
|
|
prompt = true
|
|
promptText = false
|
|
this.awaitingPromptText = true
|
|
}
|
|
index = end
|
|
continue
|
|
}
|
|
if (kind === '[') {
|
|
let end = escape + 2
|
|
while (end < this.pending.length) {
|
|
const code = this.pending.charCodeAt(end)
|
|
if (code >= 0x40 && code <= 0x7e) break
|
|
end += 1
|
|
}
|
|
if (end >= this.pending.length) {
|
|
index = escape
|
|
break
|
|
}
|
|
index = end + 1
|
|
continue
|
|
}
|
|
// Two-byte escape family (save/restore cursor and similar).
|
|
index = escape + 2
|
|
}
|
|
this.pending = this.pending.slice(index)
|
|
this.enforcePendingBound()
|
|
return { text: this.normalizeText(text), prompt, ...promptText ? { promptText: true } : {} }
|
|
}
|
|
|
|
/**
|
|
* Flush a trailing printable fragment when the PTY exits.
|
|
* @returns Remaining printable text; incomplete escapes are discarded.
|
|
*/
|
|
flush(): string {
|
|
const text = this.pending.startsWith('\x1b') ? '' : this.pending
|
|
this.pending = ''
|
|
this.discardMode = undefined
|
|
this.discardOscEscape = false
|
|
this.awaitingPromptText = false
|
|
const normalized = this.normalizeText(text)
|
|
if (!this.trailingCarriageReturn) return normalized
|
|
this.trailingCarriageReturn = false
|
|
return `${normalized}\n`
|
|
}
|
|
|
|
private normalizeText(text: string): string {
|
|
let complete = this.trailingCarriageReturn ? `\r${text}` : text
|
|
this.trailingCarriageReturn = false
|
|
if (complete.endsWith('\r')) {
|
|
complete = complete.slice(0, -1)
|
|
this.trailingCarriageReturn = true
|
|
}
|
|
return normalizeTerminalText(complete)
|
|
}
|
|
|
|
private enforcePendingBound(): void {
|
|
if (Buffer.byteLength(this.pending) <= this.maxPendingBytes) return
|
|
this.discardMode = this.pending[1] === ']' ? 'osc' : 'csi'
|
|
this.pending = ''
|
|
}
|
|
|
|
private discardPrefix(chunk: string): string {
|
|
if (this.discardMode === undefined) return chunk
|
|
if (this.discardMode === 'csi') {
|
|
for (let index = 0; index < chunk.length; index += 1) {
|
|
const code = chunk.charCodeAt(index)
|
|
if (code >= 0x40 && code <= 0x7e) {
|
|
this.discardMode = undefined
|
|
return chunk.slice(index + 1)
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
let index = 0
|
|
if (this.discardOscEscape) {
|
|
this.discardOscEscape = false
|
|
if (chunk.startsWith('\\')) {
|
|
this.discardMode = undefined
|
|
return chunk.slice(1)
|
|
}
|
|
}
|
|
while (index < chunk.length) {
|
|
if (chunk[index] === '\x07') {
|
|
this.discardMode = undefined
|
|
return chunk.slice(index + 1)
|
|
}
|
|
if (chunk[index] === '\x1b') {
|
|
if (chunk[index + 1] === '\\') {
|
|
this.discardMode = undefined
|
|
return chunk.slice(index + 2)
|
|
}
|
|
if (index + 1 === chunk.length) this.discardOscEscape = true
|
|
}
|
|
index += 1
|
|
}
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize CRLF and standalone carriage returns for line-oriented rendering.
|
|
* @param text - sanitized terminal text.
|
|
* @returns Line-normalized text with BEL removed.
|
|
*/
|
|
export function normalizeTerminalText(text: string): string {
|
|
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n').replaceAll('\x07', '')
|
|
}
|