Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,29 @@ This eliminates the need for polling—perfect for long-running processes like b
| Variable | Default | Description |
| ---------------------- | ---------- | -------------------------------------------------- |
| `PTY_MAX_BUFFER_LINES` | `50000` | Maximum lines to keep in output buffer per session |
| `PTY_SANITIZE_OUTPUT` | `true` | Strip ANSI/VT escape sequences from `pty_read` results and `<pty_exited>` notifications before they are stored in the OpenCode session. Set to `false` to preserve raw terminal output. |
| `PTY_WEB_HOSTNAME` | `::1` | Hostname for the web server to bind to (IPv6 loopback by default) |
| `PTY_WEB_PORT` | `0` (random) | Port for the web server (0 = random port) |

### Plugin Options

The plugin also accepts options from your opencode config (V1 `plugin` array entry / V2 `plugins` entry):

```jsonc
{
"plugin": [
["opencode-pty", { "sanitizeOutput": false }]
]
}
```

| Option | Default | Description |
| ----------------- | ------- | ------------------------------------------------------------------ |
| `sanitizeOutput` | `true` | Same as `PTY_SANITIZE_OUTPUT`; when both are set, the option wins. |
| `port` | — | Fixed port for the Web UI observer server (V2 only). |
| `hostname` | — | Hostname for the Web UI observer server (V2 only). |
| `autostart` | `false` | Start the Web UI observer server on plugin init (V2 only). |

### Permissions

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.
Expand Down
7 changes: 6 additions & 1 deletion src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ import { ptyWrite } from './plugin/pty/tools/write.ts'
import { ptyRead } from './plugin/pty/tools/read.ts'
import { ptyList } from './plugin/pty/tools/list.ts'
import { ptyKill } from './plugin/pty/tools/kill.ts'
import { setAnsiSanitizationOverride } from './plugin/pty/sanitize.ts'
import { PTYServer } from './web/server/server.ts'
import open from 'open'

const ptyOpenClientCommand = 'pty-open-background-spy'
const ptyShowServerUrlCommand = 'pty-show-server-url'

export const PTYPlugin = async (context: PluginContext): Promise<PluginResult> => {
export const PTYPlugin = async (
context: PluginContext,
options?: { sanitizeOutput?: boolean }
): Promise<PluginResult> => {
const { client } = context
setAnsiSanitizationOverride(options?.sanitizeOutput)
const adapter = createV1Adapter(context)
installHostAdapter(adapter)
let ptyServer: PTYServer | undefined
Expand Down
13 changes: 9 additions & 4 deletions src/plugin/pty/notification-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { SessionNotifier } from '../../adapters/types.ts'
import type { PTYSession } from './types.ts'
import type { OpencodeClient } from '@opencode-ai/sdk'
import { NOTIFICATION_LINE_TRUNCATE, NOTIFICATION_TITLE_TRUNCATE } from '../constants.ts'
import { sanitizeAnsi } from './sanitize.ts'

export class NotificationManager implements SessionNotifier {
private client: OpencodeClient | null = null
Expand Down Expand Up @@ -69,11 +70,15 @@ export function buildExitNotification(session: PTYSession, exitCode: number): st
for (let i = lineCount - 1; i >= 0; i--) {
const bufferLines = session.buffer.read(i, 1)
const line = bufferLines[0]
if (line !== undefined && line.trim() !== '') {
if (line !== undefined) {
const sanitized = sanitizeAnsi(line)
if (sanitized.trim() === '') {
continue
}
lastLine =
line.length > NOTIFICATION_LINE_TRUNCATE
? `${line.slice(0, NOTIFICATION_LINE_TRUNCATE)}...`
: line
sanitized.length > NOTIFICATION_LINE_TRUNCATE
? `${sanitized.slice(0, NOTIFICATION_LINE_TRUNCATE)}...`
: sanitized
break
}
}
Expand Down
11 changes: 9 additions & 2 deletions src/plugin/pty/output-manager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PTYSession, ReadResult, SearchResult } from './types.ts'
import { sanitizeAnsi } from './sanitize.ts'

export class OutputManager {
write(session: PTYSession, data: string): boolean {
Expand All @@ -11,7 +12,7 @@ export class OutputManager {
}

read(session: PTYSession, offset: number = 0, limit?: number): ReadResult {
const lines = session.buffer.read(offset, limit)
const lines = session.buffer.read(offset, limit).map((line) => sanitizeAnsi(line))
const totalLines = session.buffer.length
const hasMore = offset + lines.length < totalLines
return { lines, totalLines, offset, hasMore }
Expand All @@ -24,6 +25,12 @@ export class OutputManager {
const paginatedMatches =
limit !== undefined ? allMatches.slice(offset, offset + limit) : allMatches.slice(offset)
const hasMore = offset + paginatedMatches.length < totalMatches
return { matches: paginatedMatches, totalMatches, totalLines, offset, hasMore }
return {
matches: paginatedMatches.map((match) => ({ ...match, text: sanitizeAnsi(match.text) })),
totalMatches,
totalLines,
offset,
hasMore,
}
}
}
32 changes: 32 additions & 0 deletions src/plugin/pty/sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
let optionOverride: boolean | undefined

/**
* Applies the `sanitizeOutput` plugin option (from the opencode plugin config).
* Precedence: explicit option > PTY_SANITIZE_OUTPUT env var > default (true).
*/
export function setAnsiSanitizationOverride(value: boolean | undefined): void {
optionOverride = value
}

export function isAnsiSanitizationEnabled(): boolean {
if (optionOverride !== undefined) {
return optionOverride
}
const rawValue = process.env.PTY_SANITIZE_OUTPUT ?? 'true'
return rawValue.toLowerCase() !== 'false' && rawValue !== '0'
}

/**
* Strips ANSI/VT escape sequences (CSI, OSC, and lone ESC) from text before
* it is returned in `pty_read` results or `<pty_exited>` notifications.
*
* The raw buffer is kept untouched so the Web UI / xterm.js stream still
* receives the original terminal output.
*/
export function sanitizeAnsi(text: string): string {
if (!isAnsiSanitizationEnabled()) {
return text
}

return Bun.stripANSI(text)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will that work on opencode v2? afaik opencode v2 is not using bun?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will that work on opencode v2? afaik opencode v2 is not using bun?

I re-checked this against the current upstream state and went through the plugin again more carefully. I'm closing this PR and will open a new one with a better-scoped fix.

The problem actually is

The plugin hands PTY output to the host as plain text in two places: pty_read results and the <pty_exited> notification. On Windows, ConPTY starts every session with mode, clear-screen and window-title sequences (ESC[?9001h ESC[?1004h ESC[?25l ESC[2J ESC[H ESC]0;C:\Program Files\PowerShell\7\pwsh.EXE BEL). For short commands, that preamble often ends up as the notification's Last Line. The OpenCode TUI writes text content to the terminal verbatim, so the terminal executes these sequences. It isn't specific to V1 or to Bun:

On V2 / Bun question

You were right. V2 ships a Bun-compiled binary, but it also builds a Node SEA (opencode2-node), and plugins run in-process. Under Node, Bun.stripANSI would throw. The new PR doesn't add any Bun dependency.

Changes in the new PR

  • Use node:util stripVTControlCharacters, plus removal of OSC/DCS/APC strings and leftover C0/C1 control characters (e.g. \r, BEL). Tabs are kept. This works on both Bun and Node.
  • Sanitize only where text leaves the plugin: pty_read and <pty_exited>. The raw buffer used by the Web UI is unchanged.
  • pty_read with pattern now matches against the sanitized text. Previously a pattern could match bytes inside escape sequences, or fail to match the visible text.
  • The exit notification skips lines that are empty after sanitizing, so the ConPTY preamble is never reported as the last line.
  • No new plugin options. There is a single PTY_SANITIZE_OUTPUT=0 escape hatch, following the existing PTY_* env vars.

New PR is here

}
2 changes: 2 additions & 0 deletions src/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ServerOptions } from '../web/server/server.ts'
import { getOrCreateServer, registerV2Commands } from './commands.ts'
import { V2SessionNotifier } from './notifier.ts'
import { registerV2Tools } from './tools.ts'
import { setAnsiSanitizationOverride } from '../plugin/pty/sanitize.ts'
import { define, type PluginContextV2, type PluginV2 } from './types.ts'

export * from './commands.ts'
Expand All @@ -18,6 +19,7 @@ export * from './types.ts'
export const Plugin: PluginV2 = define({
id: 'opencode-pty',
setup: async (ctx: PluginContextV2) => {
setAnsiSanitizationOverride(ctx.options?.sanitizeOutput)
// opencode v2 plugin contexts are server clients: `ctx.session.prompt`
// wakes a session with a user prompt, preserving the session's current
// model by construction. Pre-2.0 hosts without the session domain still
Expand Down
8 changes: 8 additions & 0 deletions src/v2/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export interface OpencodePtyOptions {
* Default is false (started on-demand when slash command is executed).
*/
autostart?: boolean

/**
* Strip ANSI/VT escape sequences from output returned by `pty_read` and
* `<pty_exited>` notifications (they re-execute in the host TUI otherwise).
* Default is true (or the PTY_SANITIZE_OUTPUT env var when set).
* The raw buffer is never modified; the Web UI stream stays untouched.
*/
sanitizeOutput?: boolean
}

/**
Expand Down
29 changes: 28 additions & 1 deletion test/notification-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, expect, it, mock } from 'bun:test'
import type { OpencodeClient } from '@opencode-ai/sdk'
import { RingBuffer } from '../src/plugin/pty/buffer.ts'
import { NotificationManager } from '../src/plugin/pty/notification-manager.ts'
import {
buildExitNotification,
NotificationManager,
} from '../src/plugin/pty/notification-manager.ts'
import type { PTYSession } from '../src/plugin/pty/types.ts'

type PromptPayload = {
Expand Down Expand Up @@ -141,6 +144,17 @@ describe('NotificationManager', () => {
)
})

it('strips ANSI escape sequences from the last line in exit notifications', () => {
const buffer = new RingBuffer()
buffer.append('line 1\n\x1b[31m\x1b[?9001h\x1b[2J\x1b]0;title\x07done\x1b[0m\n')

const session = createSession({ buffer })
const text = buildExitNotification(session, 0)

expect(text).toContain('Last Line: done')
expect(text).not.toContain('\x1b')
})

it('includes timeout context when the session timed out', async () => {
const promptAsync = mock(async (_payload: PromptPayload) => {})
const manager = new NotificationManager()
Expand All @@ -159,3 +173,16 @@ describe('NotificationManager', () => {
expect(text).toContain('Process reached its PTY timeout and was stopped automatically.')
})
})

describe('buildExitNotification edge cases', () => {
it('skips lines that become empty after sanitization', () => {
const buffer = new RingBuffer()
buffer.append('real output\n')
buffer.append('\x1b[2J\x1b[?25l\n')

const session = createSession({ buffer })
const text = buildExitNotification(session, 0)

expect(text).toContain('Last Line: real output')
})
})
54 changes: 54 additions & 0 deletions test/output-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'bun:test'
import { RingBuffer } from '../src/plugin/pty/buffer.ts'
import { OutputManager } from '../src/plugin/pty/output-manager.ts'
import type { PTYSession } from '../src/plugin/pty/types.ts'

function createSession(bufferContent: string): PTYSession {
const buffer = new RingBuffer()
buffer.append(bufferContent)
return {
id: 'pty_test',
title: 'Test Session',
command: 'echo',
args: ['hello'],
workdir: '/tmp',
status: 'running',
pid: 12345,
createdAt: new Date(),
parentSessionId: 'parent-session-id',
notifyOnExit: false,
timedOut: false,
buffer,
process: null,
}
}

describe('OutputManager', () => {
const manager = new OutputManager()

it('strips ANSI escape sequences from read output', () => {
const session = createSession('\x1b[31mred\x1b[0m\n\x1b[?9001h\x1b[2Jplain\n')

const result = manager.read(session)

expect(result.lines).toEqual(['red', 'plain'])
})

it('strips ANSI escape sequences from search matches', () => {
const session = createSession('\x1b[31mred error\x1b[0m\n\x1b[32mgreen ok\x1b[0m\n')

const result = manager.search(session, /error/)

expect(result.matches).toHaveLength(1)
expect(result.matches[0]?.text).toBe('red error')
expect(result.matches[0]?.lineNumber).toBe(1)
})

it('leaves plain text unchanged', () => {
const session = createSession('hello world\nno escapes\n')

const result = manager.read(session)

expect(result.lines).toEqual(['hello world', 'no escapes'])
})
})
73 changes: 73 additions & 0 deletions test/sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'bun:test'
import { sanitizeAnsi } from '../src/plugin/pty/sanitize.ts'

describe('sanitizeAnsi', () => {
it('strips CSI escape sequences', () => {
expect(sanitizeAnsi('\x1b[?9001h')).toBe('')
expect(sanitizeAnsi('\x1b[?1004h')).toBe('')
expect(sanitizeAnsi('\x1b[?25l')).toBe('')
expect(sanitizeAnsi('\x1b[2J')).toBe('')
expect(sanitizeAnsi('\x1b[31mred\x1b[0m')).toBe('red')
})

it('strips OSC escape sequences', () => {
expect(sanitizeAnsi('\x1b]0;window title\x07')).toBe('')
expect(sanitizeAnsi('before\x1b]0;title\x07after')).toBe('beforeafter')
})

it('strips a lone ESC character', () => {
expect(sanitizeAnsi('hello\x1b')).toBe('hello')
expect(sanitizeAnsi('hello\x1b\x1b')).toBe('hello')
})

it('leaves plain text unchanged', () => {
expect(sanitizeAnsi('hello world')).toBe('hello world')
expect(sanitizeAnsi('line1\nline2')).toBe('line1\nline2')
expect(sanitizeAnsi('error: file not found')).toBe('error: file not found')
})

it('handles mixed real-world terminal output', () => {
const raw =
'\x1b[?9001h\x1b[?1004h\x1b[?25l\x1b[2J\x1b]0;playwright\x07Running 1 test\n\x1b[32m✓ passed\x1b[0m'
expect(sanitizeAnsi(raw)).toBe('Running 1 test\n✓ passed')
})

it('can be disabled via PTY_SANITIZE_OUTPUT=false', () => {
const original = process.env.PTY_SANITIZE_OUTPUT
process.env.PTY_SANITIZE_OUTPUT = 'false'
try {
expect(sanitizeAnsi('\x1b[31mred\x1b[0m')).toBe('\x1b[31mred\x1b[0m')
} finally {
process.env.PTY_SANITIZE_OUTPUT = original
}
})
})

describe('sanitizeOutput plugin option', () => {
it('plugin option overrides the env var', async () => {
const { setAnsiSanitizationOverride } = await import('../src/plugin/pty/sanitize.ts')
const original = process.env.PTY_SANITIZE_OUTPUT
process.env.PTY_SANITIZE_OUTPUT = 'true'
try {
setAnsiSanitizationOverride(false)
expect(sanitizeAnsi('\x1b[31mred\x1b[0m')).toBe('\x1b[31mred\x1b[0m')
setAnsiSanitizationOverride(true)
expect(sanitizeAnsi('\x1b[31mred\x1b[0m')).toBe('red')
} finally {
setAnsiSanitizationOverride(undefined)
process.env.PTY_SANITIZE_OUTPUT = original
}
})

it('falls back to the env var when no option is set', async () => {
const { setAnsiSanitizationOverride } = await import('../src/plugin/pty/sanitize.ts')
const original = process.env.PTY_SANITIZE_OUTPUT
process.env.PTY_SANITIZE_OUTPUT = 'false'
try {
setAnsiSanitizationOverride(undefined)
expect(sanitizeAnsi('\x1b[31mred\x1b[0m')).toBe('\x1b[31mred\x1b[0m')
} finally {
process.env.PTY_SANITIZE_OUTPUT = original
}
})
})
Loading