Skip to content
Open
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
115 changes: 99 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A plugin for [OpenCode](https://opencode.ai) that provides interactive PTY (pseu

## Why?

OpenCode's built-in `bash` tool runs commands synchronouslythe agent waits for completion. This works for quick commands, but not for:
OpenCode's built-in `bash` tool runs commands synchronously -- the agent waits for completion. This works for quick commands, but not for:

- **Dev servers** (`npm run dev`, `cargo watch`)
- **Watch modes** (`npm test -- --watch`)
Expand All @@ -20,6 +20,9 @@ This plugin gives the agent full control over multiple terminal sessions, like t
- **Interactive Input**: Send keystrokes, Ctrl+C, arrow keys, etc.
- **Output Buffer**: Read output anytime with pagination (offset/limit)
- **Pattern Filtering**: Search output using regex (like `grep`)
- **Terminal Snapshots**: Capture clean, parsed terminal screen state (no ANSI noise)
- **Screen Diffing**: Seq-based history with line-level diffs between snapshots
- **Conditional Waiting**: Block until screen matches a regex or stabilizes
- **Exit Notifications**: Get notified when processes finish (eliminates polling)
- **Permission Support**: Respects OpenCode's bash permission settings
- **Session Lifecycle**: Sessions persist until explicitly killed
Expand Down Expand Up @@ -53,13 +56,15 @@ opencode

## Tools Provided

| Tool | Description |
| ----------- | --------------------------------------------------------------------------- |
| `pty_spawn` | Create a new PTY session (command, args, workdir, env, title, notifyOnExit) |
| `pty_write` | Send input to a PTY (text, escape sequences like `\x03` for Ctrl+C) |
| `pty_read` | Read output buffer with pagination and optional regex filtering |
| `pty_list` | List all PTY sessions with status, PID, line count |
| `pty_kill` | Terminate a PTY, optionally cleanup the buffer |
| Tool | Description |
| -------------------- | --------------------------------------------------------------------------- |
| `pty_spawn` | Create a new PTY session (command, args, workdir, env, title, notifyOnExit) |
| `pty_write` | Send input to a PTY (text, escape sequences like `\x03` for Ctrl+C) |
| `pty_read` | Read raw output buffer with pagination and optional regex filtering |
| `pty_snapshot` | Capture parsed terminal screen as clean text with cursor, size, and hash |
| `pty_snapshot_wait` | Block until screen matches a regex or content stabilizes |
| `pty_list` | List all PTY sessions with status, PID, line count |
| `pty_kill` | Terminate a PTY, optionally cleanup the buffer |

## Slash Commands

Expand Down Expand Up @@ -215,7 +220,81 @@ Last Line: Build completed successfully.
Use pty_read to check the full output.
```

This eliminates the need for polling—perfect for long-running processes like builds, tests, or deployment scripts. If the process fails (non-zero exit code), the notification will suggest using `pty_read` with the `pattern` parameter to search for errors.
This eliminates the need for polling -- perfect for long-running processes like builds, tests, or deployment scripts. If the process fails (non-zero exit code), the notification will suggest using `pty_read` with the `pattern` parameter to search for errors.

### Capture a clean terminal snapshot

`pty_read` returns raw output including ANSI escape sequences, which is fine for line-oriented programs like `npm install`. But for TUI apps (interactive UIs with cursor movement, colors, screen clearing), the raw buffer is an unreadable flood of control codes. `pty_snapshot` solves this:

```
pty_snapshot: id="pty_a1b2c3d4"
→ Returns clean screen text with cursor position, size, seq number, and content hash
```

### Track screen changes with seq-based diffs

Every snapshot gets a monotonically increasing sequence number (`seq`) that increments only when the screen content actually changes. Pass `since` to get only the lines that changed:

```
pty_snapshot: id="pty_a1b2c3d4", since=5
→ Returns only changed/added/removed lines since seq 5
```

### Wait for specific screen content

Instead of polling in a loop, use `pty_snapshot_wait` to block until a condition is met:

```
pty_snapshot_wait: id="pty_a1b2c3d4", search="error|Error", timeout=30000
→ Blocks until "error" or "Error" appears on screen, or times out

pty_snapshot_wait: id="pty_a1b2c3d4", hashStableMs=2000, timeout=30000
→ Blocks until screen content is unchanged for 2 seconds (useful for "wait until done")
```

Both parameters can be combined with `since` to get a diff on return.

### Example: Debugging OpenCode itself

One compelling use case is running OpenCode inside OpenCode to observe TUI behavior during development. The agent can interact with the inner instance, send prompts, open menus, and watch exactly how the screen updates:

```
# 1. Launch OpenCode as a background TUI process
pty_spawn: command="opencode", args=["path/to/project"], title="Inner OpenCode"
→ pty_abc123

# 2. Wait for it to render, get initial screen state
pty_snapshot_wait: id="pty_abc123", hashStableMs=2000, timeout=15000
→ seq=2, shows OpenCode banner + input field

# 3. Type a prompt and submit
pty_write: id="pty_abc123", data="explain this codebase"
pty_write: id="pty_abc123", data="\n"

# 4. Watch the response stream in, frame by frame
pty_snapshot_wait: id="pty_abc123", hashStableMs=300, since=2
→ seq=15, diff shows partial response text appearing

pty_snapshot_wait: id="pty_abc123", hashStableMs=300, since=15
→ seq=28, more text streamed in

pty_snapshot_wait: id="pty_abc123", hashStableMs=3000, since=28
→ seq=46, response complete (stable for 3s)

# 5. Open the command palette
pty_write: id="pty_abc123", data="\x10"

# 6. See all available commands
pty_snapshot: id="pty_abc123", since=46
→ Shows command palette overlay with menu items

# 7. Toggle the sidebar
pty_write: id="pty_abc123", data="show sidebar\r"
pty_snapshot: id="pty_abc123", since=48
→ Sidebar appears with session info, MCP connections, context usage
```

This works because `pty_snapshot` maintains a headless terminal emulator ([xterm.js](https://xtermjs.org/)) alongside each PTY session, producing the same parsed screen a human would see -- without any ANSI escape sequence noise.

## Configuration

Expand Down Expand Up @@ -269,13 +348,16 @@ This plugin respects OpenCode's [permission settings](https://opencode.ai/docs/p
## How It Works

1. **Spawn**: Creates a PTY using [bun-pty](https://github.com/nicksrandall/bun-pty), runs command in background
2. **Buffer**: Output is captured into a rolling line buffer (ring buffer)
3. **Read**: Agent can read buffer anytime with offset/limit pagination
4. **Filter**: Optional regex pattern filters lines before pagination
5. **Write**: Agent can send any input including escape sequences
6. **Lifecycle**: Sessions track status (running/exited/killed), persist until cleanup
7. **Notify**: When `notifyOnExit` is true, sends a message to the session when the process exits
8. **Web UI**: React frontend connects via WebSocket for real-time updates
2. **Buffer**: Output is captured into both a rolling line buffer (ring buffer) and a headless terminal emulator
3. **Read**: Agent can read raw buffer anytime with offset/limit pagination
4. **Snapshot**: Agent can capture the parsed visible screen (clean text, no ANSI codes) via the headless terminal
5. **Diff**: Each content change gets a sequence number; agent can request line-level diffs between any two states
6. **Wait**: Agent can block until screen content matches a regex or stabilizes (no polling needed)
7. **Filter**: Optional regex pattern filters raw buffer lines before pagination
8. **Write**: Agent can send any input including escape sequences
9. **Lifecycle**: Sessions track status (running/exited/killed), persist until cleanup
10. **Notify**: When `notifyOnExit` is true, sends a message to the session when the process exits
11. **Web UI**: React frontend connects via WebSocket for real-time updates

## Session Lifecycle

Expand Down Expand Up @@ -525,3 +607,4 @@ Contributions are welcome! Please open an issue or submit a PR.

- [OpenCode](https://opencode.ai) - The AI coding assistant this plugin extends
- [bun-pty](https://github.com/nicksrandall/bun-pty) - Cross-platform PTY for Bun
- [xterm.js](https://xtermjs.org/) - Headless terminal emulator powering `pty_snapshot`
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 20 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"name": "opencode-pty",
"module": "index.ts",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"module": "dist/index.js",
"version": "0.2.3",
"description": "OpenCode plugin for interactive PTY management - run background processes, send input, read output with regex filtering",
"author": "shekohex",
Expand All @@ -26,27 +28,33 @@
},
"homepage": "https://github.com/shekohex/opencode-pty#readme",
"files": [
"index.ts",
"src",
"dist"
],
"license": "MIT",
"type": "module",
"exports": {
"./*": "./src/*.ts",
"./*/*": "./src/*/*.ts",
"./*/*/*": "./src/*/*/*.ts",
"./*/*/*/*": "./src/*/*/*/*.ts",
"./*/*/*/*/*": "./src/*/*/*/*/*.ts"
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./*": {
"types": "./dist/src/*.d.ts",
"default": "./dist/src/*.js"
},
"./*/*": {
"types": "./dist/src/*/*.d.ts",
"default": "./dist/src/*/*.js"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"unittest": "bun test",
"test:e2e": "PW_DISABLE_TS_ESM=1 NODE_ENV=test bun --bun playwright test",
"test:all": "bun unittest && bun test:e2e",
"build:dev": "bun clean && vite build --mode development",
"build:prod": "bun clean && vite build --mode production",
"prepack": "bun build:prod",
"build:plugin": "tsc -p tsconfig.build.json && bun x copyfiles -u 0 \"src/**/*.txt\" dist",
"build:dev": "bun clean && bun build:plugin && vite build --mode development",
"build:prod": "bun clean && bun build:plugin && vite build --mode production",
"prepublishOnly": "bun build:prod",
"clean": "rm -rf dist playwright-report test-results",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
Expand Down Expand Up @@ -78,6 +86,7 @@
"dependencies": {
"@opencode-ai/plugin": "^1.1.51",
"@opencode-ai/sdk": "^1.1.51",
"@xterm/headless": "^6.0.0",
"bun-pty": "^0.4.8",
"moment": "^2.30.1",
"open": "^11.0.0"
Expand Down
4 changes: 4 additions & 0 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ 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 { ptySnapshot } from './plugin/pty/tools/snapshot.ts'
import { ptySnapshotWait } from './plugin/pty/tools/snapshot-wait.ts'
import { PTYServer } from './web/server/server.ts'
import open from 'open'

Expand Down Expand Up @@ -48,6 +50,8 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<P
pty_spawn: ptySpawn,
pty_write: ptyWrite,
pty_read: ptyRead,
pty_snapshot: ptySnapshot,
pty_snapshot_wait: ptySnapshotWait,
pty_list: ptyList,
pty_kill: ptyKill,
},
Expand Down
32 changes: 31 additions & 1 deletion src/plugin/pty/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ import { version as bunPtyVersion } from 'bun-pty/package.json'
import { NotificationManager } from './notification-manager.ts'
import { OutputManager } from './output-manager.ts'
import { SessionLifecycleManager } from './session-lifecycle.ts'
import type { PTYSessionInfo, ReadResult, SearchResult, SpawnOptions } from './types.ts'
import type { SnapshotDiff, WaitCondition, WaitResult } from './snapshot.ts'
import type {
PTYSessionInfo,
PTYStatus,
ReadResult,
SearchResult,
SnapshotResult,
SpawnOptions,
} from './types.ts'
import { withSession } from './utils.ts'

// Monkey-patch bun-pty to fix race condition in _startReadLoop
Expand Down Expand Up @@ -159,6 +167,28 @@ class PTYManager {
)
}

snapshot(id: string): SnapshotResult | null {
return withSession(this.lifecycleManager, id, (session) => this.outputManager.snapshot(session), null)
}

snapshotDiff(id: string, sinceSeq: number): (SnapshotDiff & { id: string; status: PTYStatus }) | null {
return withSession(
this.lifecycleManager,
id,
(session) => this.outputManager.snapshotDiff(session, sinceSeq),
null
)
}

async snapshotWait(
id: string,
condition: WaitCondition
): Promise<(WaitResult & { id: string; status: string }) | null> {
const session = this.lifecycleManager.getSession(id)
if (!session) return null
return this.outputManager.snapshotWait(session, condition)
}

kill(id: string, cleanup: boolean = false): boolean {
return this.lifecycleManager.kill(id, cleanup)
}
Expand Down
35 changes: 34 additions & 1 deletion 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 type { SnapshotDiff, WaitCondition, WaitResult } from './snapshot.ts'
import type { PTYSession, PTYStatus, ReadResult, SearchResult, SnapshotResult } from './types.ts'

export class OutputManager {
write(session: PTYSession, data: string): boolean {
Expand Down Expand Up @@ -26,4 +27,36 @@ export class OutputManager {
const hasMore = offset + paginatedMatches.length < totalMatches
return { matches: paginatedMatches, totalMatches, totalLines, offset, hasMore }
}

snapshot(session: PTYSession): SnapshotResult {
return {
id: session.id,
status: session.status,
...session.snapshot.getState(),
}
}

snapshotDiff(
session: PTYSession,
sinceSeq: number
): SnapshotDiff & { id: string; status: PTYStatus } {
const diff = session.snapshot.getDiff(sinceSeq)
return {
...diff,
id: session.id,
status: session.status,
}
}

async snapshotWait(
session: PTYSession,
condition: WaitCondition
): Promise<WaitResult & { id: string; status: PTYSession['status'] }> {
const result = await session.snapshot.waitForCondition(condition)
return {
...result,
id: session.id,
status: session.status,
}
}
}
7 changes: 7 additions & 0 deletions src/plugin/pty/session-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { spawn, type IPty } from 'bun-pty'
import { RingBuffer } from './buffer.ts'
import { TerminalSnapshot } from './snapshot.ts'
import type { PTYSession, PTYSessionInfo, SpawnOptions } from './types.ts'
import { DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS } from '../constants.ts'
import moment from 'moment'
Expand All @@ -24,6 +25,7 @@ export class SessionLifecycleManager {
opts.title ?? (`${opts.command} ${args.join(' ')}`.trim() || `Terminal ${id.slice(-4)}`)

const buffer = new RingBuffer()
const snapshot = new TerminalSnapshot(DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS)
return {
id,
title,
Expand All @@ -39,6 +41,7 @@ export class SessionLifecycleManager {
parentAgent: opts.parentAgent,
notifyOnExit: opts.notifyOnExit ?? false,
buffer,
snapshot,
process: null, // will be set
}
}
Expand All @@ -63,6 +66,7 @@ export class SessionLifecycleManager {
): void {
session.process?.onData((data: string) => {
session.buffer.append(data)
session.snapshot.write(data)
onData(session, data)
})

Expand Down Expand Up @@ -143,6 +147,8 @@ export class SessionLifecycleManager {
}

toInfo(session: PTYSession): PTYSessionInfo {
const snapshot = session.snapshot.getState()

return {
id: session.id,
title: session.title,
Expand All @@ -156,6 +162,7 @@ export class SessionLifecycleManager {
pid: session.pid,
createdAt: session.createdAt.toISOString(true),
lineCount: session.buffer.length,
size: snapshot.size,
}
}
}
Loading