diff --git a/README.md b/README.md index 014cafb..c3decfb 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/src/plugin.ts b/src/plugin.ts index 4b6c2de..8c8d346 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -17,13 +17,17 @@ export const PTYPlugin = async (context: PluginContext): Promise = 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) diff --git a/src/v2/commands.ts b/src/v2/commands.ts index 83af113..cdedc0d 100644 --- a/src/v2/commands.ts +++ b/src/v2/commands.ts @@ -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 { - 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 { const server = await getOrCreateServer(options) diff --git a/src/v2/index.ts b/src/v2/index.ts index ab803a4..71f2678 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -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' @@ -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, }) } }, diff --git a/src/web/server/server.ts b/src/web/server/server.ts index 02bf4ae..2ec86bf 100644 --- a/src/web/server/server.ts +++ b/src/web/server/server.ts @@ -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, options?: ServerOptions) { this.staticRoutes = staticRoutes this.options = options @@ -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 { @@ -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 { + 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 { const port = this.options?.port ?? (process.env.PTY_WEB_PORT ? parseInt(process.env.PTY_WEB_PORT, 10) : 0) diff --git a/test/v1.test.ts b/test/v1.test.ts new file mode 100644 index 0000000..f8ea99a --- /dev/null +++ b/test/v1.test.ts @@ -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) + }) +}) diff --git a/test/v2.test.ts b/test/v2.test.ts index d8afd8d..88825d2 100644 --- a/test/v2.test.ts +++ b/test/v2.test.ts @@ -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:')