Skip to content

Commit 83b86c1

Browse files
committed
fix(pty): strip terminal control sequences from pty_read and pty_exited output
PTY output is handed to the host as plain text: pty_read results and the <pty_exited> notification. Escape sequences in it (on Windows, ConPTY prefixes every session with mode, clear-screen and window-title sequences) carry no information for the agent and are executed by the OpenCode TUI, which writes text content to the terminal verbatim in both V1 and V2. Strip them with node:util stripVTControlCharacters plus string-type sequences (OSC/DCS/APC) and leftover C0/C1 control characters, so it works under both Bun and Node hosts. Search now matches against the sanitized text, and the exit notification skips lines that are empty once sanitized. The raw buffer used by the Web UI is unchanged. PTY_SANITIZE_OUTPUT=0 restores the raw output. Fixes #75
1 parent b6c2067 commit 83b86c1

10 files changed

Lines changed: 309 additions & 24 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ This eliminates the need for polling—perfect for long-running processes like b
294294
| `PTY_MAX_BUFFER_LINES` | `50000` | Maximum lines to keep in output buffer per session |
295295
| `PTY_WEB_HOSTNAME` | `::1` | Hostname for the web server to bind to (IPv6 loopback by default) |
296296
| `PTY_WEB_PORT` | `0` (random) | Port for the web server (0 = random port) |
297+
| `PTY_SANITIZE_OUTPUT` | enabled | Strip terminal control sequences from `pty_read` output and `<pty_exited>` notifications; set to `0` or `false` to get the raw output |
297298

298299
### Permissions
299300

‎src/plugin/pty/buffer.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,18 @@ export class RingBuffer {
4242
return this.buffer
4343
}
4444

45-
search(pattern: RegExp): SearchMatch[] {
45+
/**
46+
* Returns the lines matching `pattern`. When `normalize` is given, each line
47+
* is normalized first and the pattern is matched against (and returns) the
48+
* normalized text.
49+
*/
50+
search(pattern: RegExp, normalize?: (line: string) => string): SearchMatch[] {
4651
const matches: SearchMatch[] = []
4752
const lines: string[] = this.splitBufferLines()
4853

4954
for (let i = 0; i < lines.length; i++) {
50-
const line = lines[i]
55+
const rawLine = lines[i]
56+
const line = rawLine !== undefined && normalize ? normalize(rawLine) : rawLine
5157
if (line && pattern.test(line)) {
5258
matches.push({ lineNumber: i + 1, text: line })
5359
}

‎src/plugin/pty/manager.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,17 @@ class PTYManager {
127127
)
128128
}
129129

130-
search(id: string, pattern: RegExp, offset: number = 0, limit?: number): SearchResult | null {
130+
search(
131+
id: string,
132+
pattern: RegExp,
133+
offset: number = 0,
134+
limit?: number,
135+
normalize?: (line: string) => string
136+
): SearchResult | null {
131137
return withSession(
132138
this.lifecycleManager,
133139
id,
134-
(session) => this.outputManager.search(session, pattern, offset, limit),
140+
(session) => this.outputManager.search(session, pattern, offset, limit, normalize),
135141
null
136142
)
137143
}

‎src/plugin/pty/notification-manager.ts‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { SessionNotifier } from '../../adapters/types.ts'
22
import type { PTYSession } from './types.ts'
33
import type { OpencodeClient } from '@opencode-ai/sdk'
44
import { NOTIFICATION_LINE_TRUNCATE, NOTIFICATION_TITLE_TRUNCATE } from '../constants.ts'
5+
import { isOutputSanitizationEnabled, sanitizeTerminalText } from './sanitize.ts'
56

67
export class NotificationManager implements SessionNotifier {
78
private client: OpencodeClient | null = null
@@ -63,19 +64,22 @@ export class NotificationManager implements SessionNotifier {
6364
* context's `ctx.session.prompt`).
6465
*/
6566
export function buildExitNotification(session: PTYSession, exitCode: number): string {
66-
const lineCount = session.buffer.length
67+
const bufferLines = session.buffer.read()
68+
const lineCount = bufferLines.length
69+
const sanitize = isOutputSanitizationEnabled()
6770
let lastLine = ''
68-
if (lineCount > 0) {
69-
for (let i = lineCount - 1; i >= 0; i--) {
70-
const bufferLines = session.buffer.read(i, 1)
71-
const line = bufferLines[0]
72-
if (line !== undefined && line.trim() !== '') {
73-
lastLine =
74-
line.length > NOTIFICATION_LINE_TRUNCATE
75-
? `${line.slice(0, NOTIFICATION_LINE_TRUNCATE)}...`
76-
: line
77-
break
78-
}
71+
for (let i = lineCount - 1; i >= 0; i--) {
72+
const rawLine = bufferLines[i]
73+
if (rawLine === undefined) continue
74+
// Lines that hold only control sequences (e.g. the ConPTY preamble) are
75+
// blank once sanitized and must not be reported as the last line.
76+
const line = sanitize ? sanitizeTerminalText(rawLine) : rawLine
77+
if (line.trim() !== '') {
78+
lastLine =
79+
line.length > NOTIFICATION_LINE_TRUNCATE
80+
? `${line.slice(0, NOTIFICATION_LINE_TRUNCATE)}...`
81+
: line
82+
break
7983
}
8084
}
8185

‎src/plugin/pty/output-manager.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@ export class OutputManager {
1717
return { lines, totalLines, offset, hasMore }
1818
}
1919

20-
search(session: PTYSession, pattern: RegExp, offset: number = 0, limit?: number): SearchResult {
21-
const allMatches = session.buffer.search(pattern)
20+
search(
21+
session: PTYSession,
22+
pattern: RegExp,
23+
offset: number = 0,
24+
limit?: number,
25+
normalize?: (line: string) => string
26+
): SearchResult {
27+
const allMatches = session.buffer.search(pattern, normalize)
2228
const totalMatches = allMatches.length
2329
const totalLines = session.buffer.length
2430
const paginatedMatches =

‎src/plugin/pty/sanitize.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { stripVTControlCharacters } from 'node:util'
2+
3+
// String-type sequences: OSC (ESC ]), DCS (ESC P), SOS (ESC X), PM (ESC ^) and
4+
// APC (ESC _), plus their C1 forms, up to BEL or ST (or the end of the line).
5+
// `stripVTControlCharacters` only knows a restricted OSC payload alphabet and
6+
// leaves e.g. Windows window titles (`ESC]0;C:\Program Files\...BEL`) behind.
7+
const STRING_SEQUENCES =
8+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal control sequences is the point
9+
/(?:\u001B[\]PX^_]|[\u0090\u0098\u009D-\u009F])[\s\S]*?(?:\u0007|\u001B\\|\u009C|$)/g
10+
11+
// C0 controls except \t and \n, DEL, and C1 controls. Whatever survives the
12+
// passes above (a lone ESC, \r, BEL, BS, ...) is still written to the terminal
13+
// verbatim by hosts that render this text.
14+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching control characters is the point
15+
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g
16+
17+
/**
18+
* Whether text handed to the host (`pty_read` results and `<pty_exited>`
19+
* notifications) is stripped of terminal control sequences.
20+
*
21+
* Enabled by default; set `PTY_SANITIZE_OUTPUT=0` (or `false`) to receive the
22+
* raw terminal output instead.
23+
*/
24+
export function isOutputSanitizationEnabled(): boolean {
25+
const value = process.env.PTY_SANITIZE_OUTPUT?.trim().toLowerCase()
26+
return value !== '0' && value !== 'false'
27+
}
28+
29+
/**
30+
* Removes terminal control sequences (CSI, OSC, ...) and remaining control
31+
* characters from a single line of PTY output, keeping tabs.
32+
*
33+
* Only applied where output leaves the plugin as text; the raw buffer is kept
34+
* intact for the Web UI terminal.
35+
*/
36+
export function sanitizeTerminalText(text: string): string {
37+
return stripVTControlCharacters(text.replace(STRING_SEQUENCES, '')).replace(
38+
CONTROL_CHARACTERS,
39+
''
40+
)
41+
}

‎src/plugin/pty/tools/read.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { manager } from '../manager.ts'
33
import { DEFAULT_READ_LIMIT, MAX_LINE_LENGTH } from '../../../shared/constants.ts'
44
import { buildSessionNotFoundError } from '../utils.ts'
55
import { formatLine } from '../formatters.ts'
6+
import { isOutputSanitizationEnabled, sanitizeTerminalText } from '../sanitize.ts'
67
import type { PTYSessionInfo } from '../types.ts'
78
import DESCRIPTION from './read.txt'
89

@@ -106,7 +107,9 @@ function handlePatternRead(
106107
): string {
107108
const regex = validateAndCreateRegex(pattern, ignoreCase)
108109

109-
const result = manager.search(id, regex, offset, limit)
110+
// Match against the text the agent will see, not the raw escape sequences.
111+
const normalize = isOutputSanitizationEnabled() ? sanitizeTerminalText : undefined
112+
const result = manager.search(id, regex, offset, limit, normalize)
110113
if (!result) {
111114
throw buildSessionNotFoundError(id)
112115
}
@@ -170,8 +173,13 @@ function handlePlainRead(
170173
)
171174
}
172175

176+
const sanitize = isOutputSanitizationEnabled()
173177
const formattedLines = result.lines.map((line, index) =>
174-
formatLine(line, result.offset + index + 1, MAX_LINE_LENGTH)
178+
formatLine(
179+
sanitize ? sanitizeTerminalText(line) : line,
180+
result.offset + index + 1,
181+
MAX_LINE_LENGTH
182+
)
175183
)
176184

177185
const paginationMessage = `(Buffer has more lines. Use offset=${result.offset + result.lines.length} to read beyond line ${result.offset + result.lines.length})`

‎test/notification-manager.test.ts‎

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { describe, expect, it, mock } from 'bun:test'
1+
import { afterEach, describe, expect, it, mock } from 'bun:test'
22
import type { OpencodeClient } from '@opencode-ai/sdk'
33
import { RingBuffer } from '../src/plugin/pty/buffer.ts'
4-
import { NotificationManager } from '../src/plugin/pty/notification-manager.ts'
4+
import {
5+
NotificationManager,
6+
buildExitNotification,
7+
} from '../src/plugin/pty/notification-manager.ts'
58
import type { PTYSession } from '../src/plugin/pty/types.ts'
69

710
type PromptPayload = {
@@ -159,3 +162,63 @@ describe('NotificationManager', () => {
159162
expect(text).toContain('Process reached its PTY timeout and was stopped automatically.')
160163
})
161164
})
165+
166+
describe('buildExitNotification', () => {
167+
const original = process.env.PTY_SANITIZE_OUTPUT
168+
169+
afterEach(() => {
170+
if (original === undefined) {
171+
delete process.env.PTY_SANITIZE_OUTPUT
172+
} else {
173+
process.env.PTY_SANITIZE_OUTPUT = original
174+
}
175+
})
176+
177+
function sessionWithOutput(output: string): PTYSession {
178+
const buffer = new RingBuffer()
179+
buffer.append(output)
180+
return createSession({ buffer })
181+
}
182+
183+
it('reports the last line without terminal control sequences', () => {
184+
delete process.env.PTY_SANITIZE_OUTPUT
185+
186+
const text = buildExitNotification(
187+
sessionWithOutput('\x1b[?25l\x1b[2J\x1b[H\r\n\x1b[32mBuild completed.\x1b[0m\x1b[K\r\n'),
188+
0
189+
)
190+
191+
expect(text).toContain('Last Line: Build completed.\n')
192+
expect(text).not.toContain('\x1b')
193+
expect(text).not.toContain('\r')
194+
})
195+
196+
it('skips trailing lines that only contain control sequences', () => {
197+
delete process.env.PTY_SANITIZE_OUTPUT
198+
199+
const text = buildExitNotification(
200+
sessionWithOutput(
201+
'real output\r\n\x1b[?9001h\x1b[?1004h\x1b]0;C:\\Windows\\pwsh.exe\x07\x1b[K'
202+
),
203+
0
204+
)
205+
206+
expect(text).toContain('Last Line: real output\n')
207+
})
208+
209+
it('reports an empty last line when the output is only control sequences', () => {
210+
delete process.env.PTY_SANITIZE_OUTPUT
211+
212+
const text = buildExitNotification(sessionWithOutput('\x1b[?9001h\x1b[?1004h\x1b[2J\x1b[H'), 0)
213+
214+
expect(text).toContain('Output Lines: 1\nLast Line: \n')
215+
})
216+
217+
it('keeps the raw last line when PTY_SANITIZE_OUTPUT=0', () => {
218+
process.env.PTY_SANITIZE_OUTPUT = '0'
219+
220+
const text = buildExitNotification(sessionWithOutput('\x1b[32mdone\x1b[0m\n'), 0)
221+
222+
expect(text).toContain('Last Line: \x1b[32mdone\x1b[0m\n')
223+
})
224+
})

‎test/pty-tools.test.ts‎

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { describe, it, expect, beforeEach, mock, spyOn, afterAll } from 'bun:test'
1+
import { describe, it, expect, beforeEach, afterEach, mock, spyOn, afterAll } from 'bun:test'
22
import { ptySpawn } from '../src/plugin/pty/tools/spawn.ts'
33
import { ptyRead } from '../src/plugin/pty/tools/read.ts'
44
import { ptyList } from '../src/plugin/pty/tools/list.ts'
55
import { RingBuffer } from '../src/plugin/pty/buffer.ts'
66
import { manager } from '../src/plugin/pty/manager.ts'
7+
import { sanitizeTerminalText } from '../src/plugin/pty/sanitize.ts'
78

89
describe('PTY Tools', () => {
910
afterAll(() => {
@@ -239,12 +240,76 @@ describe('PTY Tools', () => {
239240

240241
const result = await ptyRead.execute(args, ctx)
241242

242-
expect(manager.search).toHaveBeenCalledWith('test-session-id', /line/, 0, 500)
243+
expect(manager.search).toHaveBeenCalledWith(
244+
'test-session-id',
245+
/line/,
246+
0,
247+
500,
248+
sanitizeTerminalText
249+
)
243250
expect(result).toContain('<pty_output id="test-session-id" status="running" pattern="line">')
244251
expect(result).toContain('00001| line 1')
245252
expect(result).toContain('(1 match from 2 total lines)')
246253
})
247254

255+
describe('terminal control sequences', () => {
256+
const original = process.env.PTY_SANITIZE_OUTPUT
257+
const ctx = {
258+
sessionID: 'parent',
259+
messageID: 'msg',
260+
agent: 'agent',
261+
abort: new AbortController().signal,
262+
metadata: () => {},
263+
ask: async () => {},
264+
directory: '/tmp',
265+
worktree: '/tmp',
266+
}
267+
268+
beforeEach(() => {
269+
spyOn(manager, 'read').mockReturnValue({
270+
lines: ['\x1b[?9001h\x1b[?1004h\x1b[2J\x1b[H', '\x1b[31merror:\x1b[0m boom\r'],
271+
offset: 0,
272+
hasMore: false,
273+
totalLines: 2,
274+
})
275+
})
276+
277+
afterEach(() => {
278+
if (original === undefined) {
279+
delete process.env.PTY_SANITIZE_OUTPUT
280+
} else {
281+
process.env.PTY_SANITIZE_OUTPUT = original
282+
}
283+
})
284+
285+
it('strips them from read output', async () => {
286+
delete process.env.PTY_SANITIZE_OUTPUT
287+
288+
const result = await ptyRead.execute({ id: 'test-session-id' }, ctx)
289+
290+
expect(result).not.toContain('\x1b')
291+
expect(result).not.toContain('\r')
292+
expect(result).toContain('00001| \n')
293+
expect(result).toContain('00002| error: boom\n')
294+
})
295+
296+
it('keeps them when PTY_SANITIZE_OUTPUT=0', async () => {
297+
process.env.PTY_SANITIZE_OUTPUT = '0'
298+
299+
const result = await ptyRead.execute({ id: 'test-session-id' }, ctx)
300+
301+
expect(result).toContain('00002| \x1b[31merror:\x1b[0m boom\r')
302+
})
303+
304+
it('does not normalize search lines when PTY_SANITIZE_OUTPUT=0', async () => {
305+
process.env.PTY_SANITIZE_OUTPUT = '0'
306+
307+
await ptyRead.execute({ id: 'test-session-id', pattern: 'boom' }, ctx)
308+
309+
expect(manager.search).toHaveBeenCalledWith('test-session-id', /boom/, 0, 500, undefined)
310+
})
311+
})
312+
248313
it('should throw for invalid session', async () => {
249314
spyOn(manager, 'get').mockReturnValue(null)
250315

@@ -378,6 +443,18 @@ describe('PTY Tools', () => {
378443
])
379444
})
380445

446+
it('should match and return normalized lines when searching with a normalizer', () => {
447+
const buffer = new RingBuffer(200)
448+
buffer.append('\x1b[?25l\x1b[2J\n\x1b[31merror:\x1b[0m boom\nok 1\n')
449+
450+
expect(buffer.search(/^error: boom$/, sanitizeTerminalText)).toEqual([
451+
{ lineNumber: 2, text: 'error: boom' },
452+
])
453+
// Digits inside escape sequences must not produce matches.
454+
expect(buffer.search(/\d/, sanitizeTerminalText)).toEqual([{ lineNumber: 3, text: 'ok 1' }])
455+
expect(buffer.readRaw()).toContain('\x1b[31m')
456+
})
457+
381458
it('should clear buffer', () => {
382459
const buffer = new RingBuffer(100)
383460
buffer.append('line1\nline2')

0 commit comments

Comments
 (0)