Skip to content

Commit 54757c0

Browse files
committed
fix(pty): retain output by line and enforce buffer limits
Store retained output as lines so counts are constant-time and reads and searches avoid splitting the full buffer. Trim discarded lines in one batch and find the exit-notification tail with a single read. Normalize negative read and search offsets, including the tool's next-page hint. Intentional contract changes: honor PTY_MAX_BUFFER_LINES alongside PTY_MAX_BUFFER_SIZE, retaining whole lines until either limit is reached; only a single oversized line is sliced to its tail. Report byteLength as UTF-8 bytes instead of UTF-16 code units. Remove the exported no-op RingBuffer.flush(). Invalid or non-positive limits fall back to the existing defaults. An oversized final line that ends with a newline stays visible to pty_read and exit notifications. Reads use native slice pagination, and the line-density explanation lives in the README rather than the tool prompt.
1 parent b6c2067 commit 54757c0

9 files changed

Lines changed: 90 additions & 63 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,12 @@ This eliminates the need for polling—perfect for long-running processes like b
292292
| Variable | Default | Description |
293293
| ---------------------- | ---------- | -------------------------------------------------- |
294294
| `PTY_MAX_BUFFER_LINES` | `50000` | Maximum lines to keep in output buffer per session |
295+
| `PTY_MAX_BUFFER_SIZE` | `1000000` | Maximum characters to keep in output buffer per session |
295296
| `PTY_WEB_HOSTNAME` | `::1` | Hostname for the web server to bind to (IPv6 loopback by default) |
296297
| `PTY_WEB_PORT` | `0` (random) | Port for the web server (0 = random port) |
297298

299+
The buffer discards older whole lines when either limit is exceeded, whichever comes first. A single oversized line keeps only its tail. With the default character limit, 50,000 lines fit only when they average at most 20 characters including newlines. Invalid or non-positive limits use the defaults.
300+
298301
### Permissions
299302

300303
This plugin respects OpenCode's [permission settings](https://opencode.ai/docs/permissions/) for the `bash` tool. Commands spawned via `pty_spawn` are checked against your `permission.bash` configuration.

‎src/plugin/pty/buffer.ts‎

Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,59 @@
1-
// Default buffer size in characters (approximately 1MB)
2-
const DEFAULT_MAX_BUFFER_SIZE = parseInt(process.env.PTY_MAX_BUFFER_SIZE || '1000000', 10)
1+
// A missing, invalid or non-positive limit falls back to the default.
2+
const envLimit = (name: string, fallback: number) =>
3+
Math.max(0, Number(process.env[name])) || fallback
34

45
export interface SearchMatch {
56
lineNumber: number
67
text: string
78
}
89

910
export class RingBuffer {
10-
private buffer: string = ''
11-
private maxSize: number
11+
private lines: string[] = ['']
12+
private size = 0
1213

13-
constructor(maxSize: number = DEFAULT_MAX_BUFFER_SIZE) {
14-
this.maxSize = maxSize
15-
}
14+
constructor(
15+
private readonly maxSize = envLimit('PTY_MAX_BUFFER_SIZE', 1_000_000),
16+
private readonly maxLines = envLimit('PTY_MAX_BUFFER_LINES', 50_000)
17+
) {}
1618

1719
append(data: string): void {
18-
this.buffer += data
19-
if (this.buffer.length > this.maxSize) {
20-
this.buffer = this.buffer.slice(-this.maxSize)
20+
const lines = data.split('\n')
21+
this.lines[this.lines.length - 1] += lines[0] ?? ''
22+
for (let i = 1; i < lines.length; i++) {
23+
this.lines.push(lines[i] ?? '')
2124
}
22-
}
25+
this.size += data.length
2326

24-
private splitBufferLines(): string[] {
25-
const lines: string[] = this.buffer.split('\n')
26-
// Remove empty string at end if buffer doesn't end with newline
27-
if (lines.length && lines[lines.length - 1] === '') {
28-
lines.pop()
27+
let start = 0
28+
while (
29+
this.length - start > 1 &&
30+
(this.length - start > this.maxLines || this.size > this.maxSize)
31+
) {
32+
this.size -= (this.lines[start]?.length ?? 0) + 1
33+
start++
34+
}
35+
if (start > 0) this.lines.splice(0, start)
36+
if (this.size > this.maxSize) {
37+
this.lines[0] = this.lines[0]?.slice(this.size - this.maxSize) ?? ''
38+
this.size = this.maxSize
2939
}
30-
return lines
3140
}
3241

3342
read(offset: number = 0, limit?: number): string[] {
34-
if (this.buffer === '') return []
35-
const lines: string[] = this.splitBufferLines()
43+
const lines = this.lines.slice(0, this.length)
3644
const start = Math.max(0, offset)
3745
const end = limit !== undefined ? start + limit : lines.length
3846
return lines.slice(start, end)
3947
}
4048

4149
readRaw(): string {
42-
return this.buffer
50+
return this.lines.join('\n')
4351
}
4452

4553
search(pattern: RegExp): SearchMatch[] {
4654
const matches: SearchMatch[] = []
47-
const lines: string[] = this.splitBufferLines()
48-
49-
for (let i = 0; i < lines.length; i++) {
50-
const line = lines[i]
55+
for (let i = 0; i < this.length; i++) {
56+
const line = this.lines[i]
5157
if (line && pattern.test(line)) {
5258
matches.push({ lineNumber: i + 1, text: line })
5359
}
@@ -56,20 +62,15 @@ export class RingBuffer {
5662
}
5763

5864
get length(): number {
59-
if (this.buffer === '') return 0
60-
const lines = this.splitBufferLines()
61-
return lines.length
65+
return this.lines.length - (this.lines[this.lines.length - 1] === '' ? 1 : 0)
6266
}
6367

6468
get byteLength(): number {
65-
return this.buffer.length
66-
}
67-
68-
flush(): void {
69-
// No-op in new implementation
69+
return Buffer.byteLength(this.readRaw())
7070
}
7171

7272
clear(): void {
73-
this.buffer = ''
73+
this.lines = ['']
74+
this.size = 0
7475
}
7576
}

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

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,20 +64,11 @@ export class NotificationManager implements SessionNotifier {
6464
*/
6565
export function buildExitNotification(session: PTYSession, exitCode: number): string {
6666
const lineCount = session.buffer.length
67-
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-
}
79-
}
80-
}
67+
const line = session.buffer.read().findLast((line) => line.trim() !== '') ?? ''
68+
const lastLine =
69+
line.length > NOTIFICATION_LINE_TRUNCATE
70+
? `${line.slice(0, NOTIFICATION_LINE_TRUNCATE)}...`
71+
: line
8172

8273
const displayTitle = session.description ?? session.title
8374
const truncatedTitle =

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ export class OutputManager {
1111
}
1212

1313
read(session: PTYSession, offset: number = 0, limit?: number): ReadResult {
14+
offset = Math.max(0, offset)
1415
const lines = session.buffer.read(offset, limit)
1516
const totalLines = session.buffer.length
1617
const hasMore = offset + lines.length < totalLines
1718
return { lines, totalLines, offset, hasMore }
1819
}
1920

2021
search(session: PTYSession, pattern: RegExp, offset: number = 0, limit?: number): SearchResult {
22+
offset = Math.max(0, offset)
2123
const allMatches = session.buffer.search(pattern)
2224
const totalMatches = allMatches.length
2325
const totalLines = session.buffer.length

‎src/plugin/pty/session-lifecycle.ts‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,7 @@ export class SessionLifecycleManager {
123123
session.process?.onExit(({ exitCode, signal }) => {
124124
this.clearSessionTimeout(session.id)
125125

126-
// Flush any remaining incomplete line in the buffer
127-
session.buffer.flush()
128-
129-
if (session.status === 'killing') {
130-
session.status = 'killed'
131-
} else {
132-
session.status = 'exited'
133-
}
126+
session.status = session.status === 'killing' ? 'killed' : 'exited'
134127
session.exitCode = exitCode
135128
session.exitSignal = signal
136129
// When the process stopped, so a reader can report how long it ran: the

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ function handlePatternRead(
127127
formatLine(match.text, match.lineNumber, MAX_LINE_LENGTH)
128128
)
129129

130-
const paginationMessage = `(${result.matches.length} of ${result.totalMatches} matches shown. Use offset=${offset + result.matches.length} to see more.)`
130+
const paginationMessage = `(${result.matches.length} of ${result.totalMatches} matches shown. Use offset=${result.offset + result.matches.length} to see more.)`
131131
const endMessage = `(${result.totalMatches} match${result.totalMatches === 1 ? '' : 'es'} from ${result.totalLines} total lines)`
132132

133133
return appendSessionReminders(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Returns:
1414
- Total line count in the buffer
1515
- Indicator if more lines are available
1616

17-
The buffer stores up to PTY_MAX_BUFFER_LINES (default: 50000) lines. Older lines are discarded when the limit is reached.
17+
The buffer stores up to PTY_MAX_BUFFER_LINES (default: 50000) lines or PTY_MAX_BUFFER_SIZE (default: 1000000) characters, whichever limit is reached first. Older lines are discarded whole; a single oversized line keeps only its tail.
1818

1919
Pattern Filtering:
2020
- When `pattern` is set, lines are FILTERED FIRST using the regex, then offset/limit apply to the MATCHES

‎test/output-manager.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { expect, it } from 'bun:test'
2+
import { RingBuffer } from '../src/plugin/pty/buffer.ts'
3+
import { OutputManager } from '../src/plugin/pty/output-manager.ts'
4+
import type { PTYSession } from '../src/plugin/pty/types.ts'
5+
6+
it('clamps negative read offsets before reporting pagination', () => {
7+
const buffer = new RingBuffer()
8+
buffer.append('line\n'.repeat(10))
9+
const result = new OutputManager().read({ buffer } as PTYSession, -40)
10+
11+
expect(result.lines).toHaveLength(10)
12+
expect({ offset: result.offset, hasMore: result.hasMore }).toEqual({
13+
offset: 0,
14+
hasMore: false,
15+
})
16+
})

‎test/pty-tools.test.ts‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -389,15 +389,36 @@ describe('PTY Tools', () => {
389389
expect(buffer.readRaw()).toBe('')
390390
})
391391

392-
it('should truncate buffer at byte level when exceeding max', () => {
392+
it('should discard whole lines when exceeding the character limit', () => {
393393
const buffer = new RingBuffer(10) // Small buffer for testing
394394
buffer.append('line1\nline2\nline3\nline4')
395395

396-
// Input is 'line1\nline2\nline3\nline4' (23 chars)
397-
// With buffer size 10, keeps last 10 chars: 'ine3\nline4'
398-
expect(buffer.readRaw()).toBe('ine3\nline4')
399-
expect(buffer.read()).toEqual(['ine3', 'line4'])
400-
expect(buffer.length).toBe(2)
396+
expect(buffer.readRaw()).toBe('line4')
397+
expect(buffer.read()).toEqual(['line4'])
398+
expect(buffer.length).toBe(1)
399+
})
400+
401+
it('should keep the tail of the newest line when it alone exceeds the character limit', () => {
402+
const buffer = new RingBuffer(10)
403+
buffer.append(`${'x'.repeat(15)}\n`)
404+
405+
expect(buffer.read()).toEqual(['x'.repeat(9)])
406+
})
407+
408+
it('should retain the last three whole lines at the line limit', () => {
409+
const buffer = new RingBuffer(100, 3)
410+
buffer.append('line1\nline2\nline3\nline4\nline5\n')
411+
412+
expect(buffer.read()).toEqual(['line3', 'line4', 'line5'])
413+
expect(buffer.readRaw()).toBe('line3\nline4\nline5\n')
414+
expect(buffer.length).toBe(3)
415+
})
416+
417+
it('should count UTF-8 bytes for non-ASCII output', () => {
418+
const buffer = new RingBuffer()
419+
buffer.append('á😀')
420+
421+
expect(buffer.byteLength).toBe(6)
401422
})
402423
})
403424
})

0 commit comments

Comments
 (0)