Skip to content
Merged
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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ This plugin gives the agent full control over multiple terminal sessions, like t

## Setup

### OpenCode V1

Add the plugin to your [OpenCode config](https://opencode.ai/docs/config/):

```json
Expand All @@ -38,7 +40,33 @@ Add the plugin to your [OpenCode config](https://opencode.ai/docs/config/):
}
```

That's it. OpenCode will automatically install the plugin on next run.
### OpenCode V2

OpenCode V2 uses the new plugin API. You can load `opencode-pty/v2` and optionally configure options (such as a fixed web UI port):

```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
{
"package": "opencode-pty/v2",
"options": {
"port": 4200,
"hostname": "127.0.0.1",
"autostart": false
}
}
]
}
```

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `port` | `number` | `0` (ephemeral) | Fixed port for the PTY Web UI observer server |
| `hostname` | `string` | `"::1"` | Hostname to bind the PTY Web UI server to |
| `autostart` | `boolean` | `false` | Automatically start the Web UI server on startup |

OpenCode will automatically install the plugin on next run.

## Updating

Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
"license": "MIT",
"type": "module",
"exports": {
"./v2": {
"types": "./dist/src/v2/index.d.ts",
"default": "./dist/src/v2/index.js"
},
"./server": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
Expand Down
19 changes: 19 additions & 0 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { manager } from '../plugin/pty/manager.ts'
import { setPermissionAuthorizer } from '../plugin/pty/permissions.ts'
import type { HostAdapter } from './types.ts'

export * from './types.ts'
export * from './v1/index.ts'

/**
* Installs a host adapter by connecting its notifier and permission authorizer
* to the core PTY manager and permission dispatcher.
*/
export function installHostAdapter(adapter: HostAdapter): void {
if (adapter.notifier) {
manager.setNotifier(adapter.notifier)
}
if (adapter.permissions) {
setPermissionAuthorizer(adapter.permissions)
}
}
28 changes: 28 additions & 0 deletions src/adapters/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { PTYSession } from '../plugin/pty/types.ts'

/**
* Host-agnostic interface for handling session exit notifications.
* Allows decoupling PTY lifecycle from any specific OpenCode client SDK.
*/
export interface SessionNotifier {
sendExitNotification(session: PTYSession, exitCode: number): Promise<void> | void
}

/**
* Host-agnostic authorizer for validating command and workdir execution permissions.
*/
export interface PermissionAuthorizer {
checkCommand(command: string, args: string[]): Promise<void>
checkWorkdir(workdir: string): Promise<void>
}

/**
* Common host adapter contract bridging a host environment (e.g. OpenCode V1, V2, Standalone)
* with the core PTY manager and execution environment.
*/
export interface HostAdapter {
readonly id: string
readonly notifier?: SessionNotifier
readonly permissions?: PermissionAuthorizer
onSessionDeleted?(sessionId: string): void
}
23 changes: 23 additions & 0 deletions src/adapters/v1/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { OpencodeClient } from '@opencode-ai/sdk'
import type { PluginContext } from '../../plugin/types.ts'
import { manager } from '../../plugin/pty/manager.ts'
import type { HostAdapter } from '../types.ts'
import { V1NotificationAdapter } from './notifications.ts'
import { V1PermissionAuthorizer } from './permissions.ts'

export { V1NotificationAdapter } from './notifications.ts'
export { V1PermissionAuthorizer } from './permissions.ts'

export function createV1Adapter(context: PluginContext): HostAdapter {
const notifier = new V1NotificationAdapter(context.client as unknown as OpencodeClient)
const permissions = new V1PermissionAuthorizer(context.client, context.directory)

return {
id: 'opencode-v1',
notifier,
permissions,
onSessionDeleted: (sessionId: string) => {
manager.cleanupBySession(sessionId)
},
}
}
23 changes: 23 additions & 0 deletions src/adapters/v1/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { OpencodeClient } from '@opencode-ai/sdk'
import { NotificationManager } from '../../plugin/pty/notification-manager.ts'
import type { PTYSession } from '../../plugin/pty/types.ts'
import type { SessionNotifier } from '../types.ts'

export class V1NotificationAdapter implements SessionNotifier {
private manager: NotificationManager

constructor(client?: OpencodeClient) {
this.manager = new NotificationManager()
if (client) {
this.manager.init(client)
}
}

init(client: OpencodeClient): void {
this.manager.init(client)
}

async sendExitNotification(session: PTYSession, exitCode: number): Promise<void> {
await this.manager.sendExitNotification(session, exitCode)
}
}
117 changes: 117 additions & 0 deletions src/adapters/v1/permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { PluginClient } from '../../plugin/types.ts'
import type { PermissionAuthorizer } from '../types.ts'
import { allStructured } from '../../plugin/pty/wildcard.ts'

type PermissionAction = 'allow' | 'ask' | 'deny'
type BashPermissions = PermissionAction | Record<string, PermissionAction>

export interface PermissionConfig {
bash?: BashPermissions
external_directory?: PermissionAction
}

export class V1PermissionAuthorizer implements PermissionAuthorizer {
constructor(
private client: PluginClient | null,
private directory: string | null
) {}

private async getPermissionConfig(): Promise<PermissionConfig> {
if (!this.client) {
return {}
}
try {
const response = await this.client.config.get()
if (response.error || !response.data) {
return {}
}
return (response.data as { permission?: PermissionConfig }).permission ?? {}
} catch {
return {}
}
}

private async showToast(
message: string,
variant: 'info' | 'success' | 'error' = 'info'
): Promise<void> {
if (!this.client) return
try {
await this.client.tui.showToast({ body: { message, variant } })
} catch {
// Ignore toast errors
}
}

private async denyWithToast(msg: string, details?: string): Promise<never> {
await this.showToast(msg, 'error')
throw new Error(details ? `${msg} ${details}` : msg)
}

private async handleAskPermission(commandLine: string): Promise<never> {
await this.denyWithToast(
`PTY: Command "${commandLine}" requires permission (treated as denied)`,
`PTY spawn denied: Command "${commandLine}" requires user permission which is not supported by this plugin. Configure explicit "allow" or "deny" in your opencode.json permission.bash settings.`
)
throw new Error('Unreachable')
}

async checkCommand(command: string, args: string[]): Promise<void> {
const config = await this.getPermissionConfig()
const bashPerms = config.bash

if (!bashPerms) {
return
}

if (typeof bashPerms === 'string') {
if (bashPerms === 'deny') {
await this.denyWithToast(
'PTY spawn denied: All bash commands are disabled by user configuration.'
)
}
if (bashPerms === 'ask') {
await this.handleAskPermission(command)
}
return
}

const action = allStructured({ head: command, tail: args }, bashPerms)

if (action === 'deny') {
await this.denyWithToast(
`PTY spawn denied: Command "${command} ${args.join(' ')}" is explicitly denied by user configuration.`
)
}

if (action === 'ask') {
await this.handleAskPermission(`${command} ${args.join(' ')}`)
}
}

async checkWorkdir(workdir: string): Promise<void> {
if (!this.directory) {
return
}

const normalizedWorkdir = workdir.replace(/\/$/, '')
const normalizedProject = this.directory.replace(/\/$/, '')

if (normalizedWorkdir.startsWith(normalizedProject)) {
return
}

const config = await this.getPermissionConfig()
const extDirPerm = config.external_directory

if (extDirPerm === 'deny') {
await this.denyWithToast(
`PTY spawn denied: Working directory "${workdir}" is outside project directory "${this.directory}". External directory access is denied by user configuration.`
)
}

if (extDirPerm === 'ask') {
// TODO: Implement user prompt for external directory access
}
}
}
26 changes: 26 additions & 0 deletions src/adapters/v2/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { manager } from '../../plugin/pty/manager.ts'
import type { HostAdapter, PermissionAuthorizer, SessionNotifier } from '../types.ts'

export interface V2AdapterOptions {
notifier?: SessionNotifier
permissions?: PermissionAuthorizer
}

export class V2HostAdapter implements HostAdapter {
readonly id = 'opencode-v2'
readonly notifier?: SessionNotifier
readonly permissions?: PermissionAuthorizer

constructor(options: V2AdapterOptions = {}) {
this.notifier = options.notifier
this.permissions = options.permissions
}

onSessionDeleted(sessionId: string): void {
manager.cleanupBySession(sessionId)
}
}

export function createV2Adapter(options?: V2AdapterOptions): HostAdapter {
return new V2HostAdapter(options)
}
12 changes: 6 additions & 6 deletions src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { PluginContext, PluginResult } from './plugin/types.ts'
import { initManager, manager } from './plugin/pty/manager.ts'
import { initPermissions } from './plugin/pty/permissions.ts'
import { createV1Adapter, installHostAdapter } from './adapters/index.ts'
import { ptySpawn } from './plugin/pty/tools/spawn.ts'
import { ptyWrite } from './plugin/pty/tools/write.ts'
import { ptyRead } from './plugin/pty/tools/read.ts'
Expand All @@ -12,9 +11,10 @@ import open from 'open'
const ptyOpenClientCommand = 'pty-open-background-spy'
const ptyShowServerUrlCommand = 'pty-show-server-url'

export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<PluginResult> => {
initPermissions(client, directory)
initManager(client)
export const PTYPlugin = async (context: PluginContext): Promise<PluginResult> => {
const { client } = context
const adapter = createV1Adapter(context)
installHostAdapter(adapter)
let ptyServer: PTYServer | undefined

return {
Expand Down Expand Up @@ -66,7 +66,7 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<P
},
event: async ({ event }) => {
if (event.type === 'session.deleted') {
manager.cleanupBySession(event.properties.info.id)
adapter.onSessionDeleted?.(event.properties.info.id)
}
},
}
Expand Down
18 changes: 17 additions & 1 deletion src/plugin/pty/manager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SessionNotifier } from '../../adapters/types.ts'
import type { OpencodeClient } from '@opencode-ai/sdk'
import { Terminal } from 'bun-pty'
import { NotificationManager } from './notification-manager.ts'
Expand Down Expand Up @@ -71,9 +72,19 @@ class PTYManager {
private lifecycleManager = new SessionLifecycleManager()
private outputManager = new OutputManager()
private notificationManager = new NotificationManager()
private notifier: SessionNotifier | null = null

setNotifier(notifier: SessionNotifier | null): void {
this.notifier = notifier
}

getNotifier(): SessionNotifier | null {
return this.notifier ?? this.notificationManager
}

init(client: OpencodeClient): void {
this.notificationManager.init(client)
this.notifier = this.notificationManager
}

clearAllSessions(): void {
Expand All @@ -89,7 +100,8 @@ class PTYManager {
async (session, exitCode) => {
notifySessionUpdate(this.lifecycleManager.toInfo(session))
if (session?.notifyOnExit) {
await this.notificationManager.sendExitNotification(session, exitCode || 0)
const activeNotifier = this.notifier ?? this.notificationManager
await activeNotifier.sendExitNotification(session, exitCode || 0)
}
}
)
Expand Down Expand Up @@ -163,3 +175,7 @@ export const manager = new PTYManager()
export function initManager(opcClient: OpencodeClient): void {
manager.init(opcClient)
}

export function setManagerNotifier(notifier: SessionNotifier | null): void {
manager.setNotifier(notifier)
}
3 changes: 2 additions & 1 deletion src/plugin/pty/notification-manager.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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'

export class NotificationManager {
export class NotificationManager implements SessionNotifier {
private client: OpencodeClient | null = null

init(client: OpencodeClient): void {
Expand Down
Loading
Loading