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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ OpenCode V2 uses the new plugin API. You can load `opencode-pty/v2` and optional
| --- | --- | --- | --- |
| `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 |
| `autostart` | `boolean` | `false` | Automatically start the Web UI server on startup (can also be enabled via `PTY_WEB_AUTOSTART=true`) |

OpenCode will automatically install the plugin on next run.

Expand Down Expand Up @@ -259,9 +259,10 @@ 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_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) |
| `PTY_MAX_BUFFER_LINES` | `50000` | Maximum lines to keep in output buffer per session |
| `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) |
| `PTY_WEB_AUTOSTART` | `false` | Automatically start the Web UI server on OpenCode startup |

### Permissions

Expand Down
6 changes: 5 additions & 1 deletion src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ export const PTYPlugin = async (context: PluginContext): Promise<PluginResult> =
installHostAdapter(adapter)
let ptyServer: PTYServer | undefined

if (PTYServer.isAutostartEnabled()) {
ptyServer = await PTYServer.getOrCreateServer()
}

return {
'command.execute.before': async (input) => {
if (input.command !== ptyOpenClientCommand && input.command !== ptyShowServerUrlCommand) {
return
}
if (ptyServer === undefined) {
ptyServer = await PTYServer.createServer()
ptyServer = await PTYServer.getOrCreateServer()
}
if (input.command === ptyOpenClientCommand) {
open(ptyServer.server.url.origin)
Expand Down
22 changes: 3 additions & 19 deletions src/v2/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,9 @@ import type { CommandDraft, OpencodePtyOptions } from './types.ts'
export const PTY_OPEN_CLIENT_COMMAND = 'pty-open-background-spy'
export const PTY_SHOW_SERVER_URL_COMMAND = 'pty-show-server-url'

let activeServer: PTYServer | null = null

export async function getOrCreateServer(options?: ServerOptions): Promise<PTYServer> {
if (!activeServer) {
activeServer = await PTYServer.createServer(options)
}
return activeServer
}

export function getActiveServer(): PTYServer | null {
return activeServer
}

export function stopActiveServer(): void {
if (activeServer) {
activeServer[Symbol.dispose]()
activeServer = null
}
}
export const getOrCreateServer = PTYServer.getOrCreateServer
export const getActiveServer = PTYServer.getActiveServer
export const stopActiveServer = PTYServer.stopActiveServer

export async function handleOpenClientCommand(options?: ServerOptions): Promise<string> {
const server = await getOrCreateServer(options)
Expand Down
10 changes: 7 additions & 3 deletions src/v2/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createV2Adapter } from '../adapters/v2/index.ts'
import { installHostAdapter } from '../adapters/index.ts'
import { PTYServer } from '../web/server/server.ts'
import { getOrCreateServer, registerV2Commands } from './commands.ts'
import { define, type PluginContextV2, type PluginV2 } from './types.ts'

Expand All @@ -24,10 +25,13 @@ export const Plugin: PluginV2 = define({
})
}

if (options?.autostart) {
const autostart =
options?.autostart !== undefined ? options.autostart : PTYServer.isAutostartEnabled()

if (autostart) {
await getOrCreateServer({
port: options.port,
hostname: options.hostname,
port: options?.port,
hostname: options?.hostname,
})
}
},
Expand Down
31 changes: 31 additions & 0 deletions src/web/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export class PTYServer implements Disposable {
private readonly stack = new DisposableStack()
private readonly options?: ServerOptions

private static activeServer: PTYServer | null = null

private constructor(staticRoutes: Record<string, Response>, options?: ServerOptions) {
this.staticRoutes = staticRoutes
this.options = options
Expand All @@ -38,6 +40,9 @@ export class PTYServer implements Disposable {

[Symbol.dispose]() {
this.stack.dispose()
if (PTYServer.activeServer === this) {
PTYServer.activeServer = null
}
}

public static async createServer(options?: ServerOptions): Promise<PTYServer> {
Expand All @@ -46,6 +51,32 @@ export class PTYServer implements Disposable {
return new PTYServer(staticRoutes, options)
}

public static isAutostartEnabled(): boolean {
const env = process.env.PTY_WEB_AUTOSTART ?? process.env.PTY_AUTOSTART
if (!env) return false
const normalized = env.trim().toLowerCase()
return (
normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'
)
}

public static async getOrCreateServer(options?: ServerOptions): Promise<PTYServer> {
if (!PTYServer.activeServer) {
PTYServer.activeServer = await PTYServer.createServer(options)
}
return PTYServer.activeServer
}

public static getActiveServer(): PTYServer | null {
return PTYServer.activeServer
}

public static stopActiveServer(): void {
if (PTYServer.activeServer) {
PTYServer.activeServer[Symbol.dispose]()
}
}

private startWebServer(): Server<undefined> {
const port =
this.options?.port ?? (process.env.PTY_WEB_PORT ? parseInt(process.env.PTY_WEB_PORT, 10) : 0)
Expand Down
94 changes: 94 additions & 0 deletions test/v1.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { afterEach, describe, expect, it, mock } from 'bun:test'
import { PTYPlugin } from '../src/plugin.ts'
import { PTYServer } from '../src/web/server/server.ts'
import type { PluginContext } from '../src/plugin/types.ts'

function createMockContext(): PluginContext {
return {
client: {
config: {
get: async () => ({
data: {
permission: {
bash: 'allow',
},
},
}),
},
tui: {
showToast: async () => {},
},
session: {
get: async () => ({ data: {} }),
prompt: async () => {},
},
},
directory: '/test/workspace',
} as unknown as PluginContext
}

describe('OpenCode V1 Plugin (PTYPlugin)', () => {
afterEach(() => {
PTYServer.stopActiveServer()
delete process.env.PTY_WEB_AUTOSTART
delete process.env.PTY_AUTOSTART
})

it('does not autostart server by default when env var is not set', async () => {
expect(PTYServer.getActiveServer()).toBeNull()
const ctx = createMockContext()
await PTYPlugin(ctx)
expect(PTYServer.getActiveServer()).toBeNull()
})

it('autostarts server on initialization when PTY_WEB_AUTOSTART is true', async () => {
expect(PTYServer.getActiveServer()).toBeNull()
process.env.PTY_WEB_AUTOSTART = 'true'

const ctx = createMockContext()
await PTYPlugin(ctx)

const active = PTYServer.getActiveServer()
expect(active).not.toBeNull()
})

it('autostarts server when PTY_AUTOSTART alias is 1', async () => {
expect(PTYServer.getActiveServer()).toBeNull()
process.env.PTY_AUTOSTART = '1'

const ctx = createMockContext()
await PTYPlugin(ctx)

const active = PTYServer.getActiveServer()
expect(active).not.toBeNull()
})

it('reuses autostarted server when pty-show-server-url command is executed', async () => {
process.env.PTY_WEB_AUTOSTART = 'true'
const ctx = createMockContext()
const promptMock = mock(async () => ({ data: undefined, error: undefined }))
ctx.client.session.prompt = promptMock as unknown as typeof ctx.client.session.prompt

const pluginResult = await PTYPlugin(ctx)
const initialServer = PTYServer.getActiveServer()
expect(initialServer).not.toBeNull()

const beforeHook = pluginResult['command.execute.before']
expect(beforeHook).toBeDefined()

expect(
beforeHook?.(
{
command: 'pty-show-server-url',
sessionID: 'session-1',
arguments: '',
},
{ parts: [] }
)
).rejects.toThrow('Command handled by PTY plugin')

expect(promptMock).toHaveBeenCalled()
const currentServer = PTYServer.getActiveServer()
expect(currentServer).toBe(initialServer)
})
})
62 changes: 62 additions & 0 deletions test/v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,68 @@ describe('OpenCode V2 Plugin API', () => {
expect(active?.server.url.hostname).toBe('127.0.0.1')
})

it('autostarts server when PTY_WEB_AUTOSTART is set in env and option is undefined', async () => {
expect(getActiveServer()).toBeNull()
process.env.PTY_WEB_AUTOSTART = 'true'

try {
const ctx: PluginContextV2 = {
options: {
hostname: '127.0.0.1',
},
}

await Plugin.setup(ctx)

const active = getActiveServer()
expect(active).not.toBeNull()
expect(active?.server.url.hostname).toBe('127.0.0.1')
} finally {
delete process.env.PTY_WEB_AUTOSTART
}
})

it('autostarts server when PTY_AUTOSTART alias is set to 1 in env', async () => {
expect(getActiveServer()).toBeNull()
process.env.PTY_AUTOSTART = '1'

try {
const ctx: PluginContextV2 = {
options: {
hostname: '127.0.0.1',
},
}

await Plugin.setup(ctx)

const active = getActiveServer()
expect(active).not.toBeNull()
} finally {
delete process.env.PTY_AUTOSTART
}
})

it('does not autostart when autostart option is explicitly false even if env is set', async () => {
expect(getActiveServer()).toBeNull()
process.env.PTY_WEB_AUTOSTART = 'true'

try {
const ctx: PluginContextV2 = {
options: {
autostart: false,
hostname: '127.0.0.1',
},
}

await Plugin.setup(ctx)

const active = getActiveServer()
expect(active).toBeNull()
} finally {
delete process.env.PTY_WEB_AUTOSTART
}
})

it('shows server URL via handleShowServerUrlCommand', async () => {
const message = await handleShowServerUrlCommand({ hostname: '127.0.0.1' })
expect(message).toContain('PTY Sessions Web Interface URL: http://127.0.0.1:')
Expand Down
Loading