diff --git a/README.md b/README.md index f91e0a4..d3f69e1 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ opencode | Tool | Description | | ----------- | --------------------------------------------------------------------------- | -| `pty_spawn` | Create a new PTY session (command, args, workdir, env, title, notifyOnExit) | +| `pty_spawn` | Create a new PTY session (command, args, workdir, env, title, notifyOnExit, timeoutSeconds) | | `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 | @@ -120,7 +120,8 @@ curl -X POST http://localhost:[PORT]/api/sessions \ -d '{ "command": "bash", "args": ["-c", "echo hello && sleep 10"], - "description": "Test session" + "description": "Test session", + "timeoutSeconds": 5 }' ``` @@ -166,6 +167,13 @@ pty_spawn: command="npm", args=["run", "dev"], title="Dev Server" → Returns: pty_a1b2c3d4 ``` +### Start a timed session + +``` +pty_spawn: command="npm", args=["run", "dev"], title="Dev Server", timeoutSeconds=600 +→ Returns: pty_a1b2c3d4 +``` + ### Check server output ``` diff --git a/src/plugin/pty/formatters.ts b/src/plugin/pty/formatters.ts index a9a5b0b..a88ed01 100644 --- a/src/plugin/pty/formatters.ts +++ b/src/plugin/pty/formatters.ts @@ -1,13 +1,16 @@ import type { PTYSessionInfo } from './types.ts' export function formatSessionInfo(session: PTYSessionInfo): string[] { + const timedOutInfo = session.timedOut ? ' | timed out' : '' const exitInfo = session.exitCode !== undefined ? ` | exit: ${session.exitCode}` : '' const exitSignal = session.exitSignal ? ` | signal: ${session.exitSignal}` : '' + const timeoutInfo = + session.timeoutSeconds !== undefined ? ` | timeout: ${session.timeoutSeconds}s` : '' return [ `[${session.id}] ${session.title}`, ` Command: ${session.command} ${session.args.join(' ')}`, - ` Status: ${session.status}${exitInfo}${exitSignal}`, - ` PID: ${session.pid}`, + ` Status: ${session.status}${timedOutInfo}${exitInfo}${exitSignal}`, + ` PID: ${session.pid}${timeoutInfo}`, ` Lines: ${session.lineCount}`, ` Workdir: ${session.workdir}`, ` Created: ${session.createdAt}`, diff --git a/src/plugin/pty/notification-manager.ts b/src/plugin/pty/notification-manager.ts index 9821534..c081e21 100644 --- a/src/plugin/pty/notification-manager.ts +++ b/src/plugin/pty/notification-manager.ts @@ -56,13 +56,19 @@ export class NotificationManager { `ID: ${session.id}`, `Description: ${truncatedTitle}`, `Exit Code: ${exitCode}`, + `TimeoutSeconds: ${session.timeoutSeconds ?? 'none'}`, + `Timed Out: ${session.timedOut ? 'yes' : 'no'}`, `Output Lines: ${lineCount}`, `Last Line: ${lastLine}`, '', '', ] - if (exitCode === 0) { + if (session.timedOut) { + lines.push( + 'Process reached its PTY timeout and was stopped automatically. Use pty_read to inspect the final output.' + ) + } else if (exitCode === 0) { lines.push('Use pty_read to check the full output.') } else { lines.push( diff --git a/src/plugin/pty/session-lifecycle.ts b/src/plugin/pty/session-lifecycle.ts index 99e9802..ac40d87 100644 --- a/src/plugin/pty/session-lifecycle.ts +++ b/src/plugin/pty/session-lifecycle.ts @@ -14,11 +14,64 @@ function generateId(): string { export class SessionLifecycleManager { private sessions: Map = new Map() + private sessionTimeouts: Map> = new Map() + + private normalizeTimeoutSeconds(timeoutSeconds: number | undefined): number | undefined { + if (timeoutSeconds === undefined) { + return undefined + } + + if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) { + throw new Error('timeoutSeconds must be a positive integer in seconds') + } + + return timeoutSeconds + } + + private clearSessionTimeout(id: string): void { + const timeoutHandle = this.sessionTimeouts.get(id) + if (!timeoutHandle) { + return + } + + clearTimeout(timeoutHandle) + this.sessionTimeouts.delete(id) + } + + private scheduleSessionTimeout(session: PTYSession): void { + if (session.timeoutSeconds === undefined) { + return + } + + const timeoutMs = session.timeoutSeconds * 1000 + + const timeoutHandle = setTimeout(() => { + this.sessionTimeouts.delete(session.id) + + const currentSession = this.sessions.get(session.id) + if (!currentSession || currentSession.status !== 'running') { + return + } + + // Persist the timeout reason before reusing the regular kill flow. + currentSession.timedOut = true + currentSession.status = 'killing' + + try { + currentSession.process?.kill() + } catch { + // Ignore kill errors + } + }, timeoutMs) + + this.sessionTimeouts.set(session.id, timeoutHandle) + } private createSessionObject(opts: SpawnOptions): PTYSession { const id = generateId() const args = opts.args ?? [] const workdir = opts.workdir ?? process.cwd() + const timeoutSeconds = this.normalizeTimeoutSeconds(opts.timeoutSeconds) const title = opts.title ?? (`${opts.command} ${args.join(' ')}`.trim() || `Terminal ${id.slice(-4)}`) @@ -37,6 +90,8 @@ export class SessionLifecycleManager { parentSessionId: opts.parentSessionId, parentAgent: opts.parentAgent, notifyOnExit: opts.notifyOnExit ?? false, + timeoutSeconds, + timedOut: false, buffer, process: null, // will be set } @@ -66,6 +121,8 @@ export class SessionLifecycleManager { }) session.process?.onExit(({ exitCode, signal }) => { + this.clearSessionTimeout(session.id) + // Flush any remaining incomplete line in the buffer session.buffer.flush() @@ -89,6 +146,7 @@ export class SessionLifecycleManager { this.spawnProcess(session) this.setupEventHandlers(session, onData, onExit) this.sessions.set(session.id, session) + this.scheduleSessionTimeout(session) return this.toInfo(session) } @@ -98,6 +156,8 @@ export class SessionLifecycleManager { return false } + this.clearSessionTimeout(id) + if (session.status === 'running') { session.status = 'killing' try { @@ -151,6 +211,8 @@ export class SessionLifecycleManager { workdir: session.workdir, status: session.status, notifyOnExit: session.notifyOnExit, + timeoutSeconds: session.timeoutSeconds, + timedOut: session.timedOut, exitCode: session.exitCode, exitSignal: session.exitSignal, pid: session.pid, diff --git a/src/plugin/pty/tools/read.ts b/src/plugin/pty/tools/read.ts index 4e22fd2..cbc2309 100644 --- a/src/plugin/pty/tools/read.ts +++ b/src/plugin/pty/tools/read.ts @@ -16,6 +16,15 @@ const NOTIFY_ON_EXIT_REMINDER = [ ``, ].join('\n') +function buildTimeoutReminder(session: PTYSessionInfo): string { + return [ + ``, + `This session was auto-killed after reaching \`timeoutSeconds=${session.timeoutSeconds ?? 'unknown'}\`.`, + `Use \`pty_read\` to inspect the final output or \`pty_list\` to review other sessions.`, + ``, + ].join('\n') +} + interface ReadArgs { id: string offset?: number @@ -54,6 +63,18 @@ function appendNotifyOnExitReminder(output: string, session: PTYSessionInfo): st return `${output}\n\n${NOTIFY_ON_EXIT_REMINDER}` } +function appendTimeoutReminder(output: string, session: PTYSessionInfo): string { + if (!session.timedOut) { + return output + } + + return `${output}\n\n${buildTimeoutReminder(session)}` +} + +function appendSessionReminders(output: string, session: PTYSessionInfo): string { + return appendTimeoutReminder(appendNotifyOnExitReminder(output, session), session) +} + /** * Validates and creates a RegExp from pattern string */ @@ -91,7 +112,7 @@ function handlePatternRead( } if (result.matches.length === 0) { - return appendNotifyOnExitReminder( + return appendSessionReminders( [ ``, `No lines matched the pattern '${pattern}'.`, @@ -109,7 +130,7 @@ function handlePatternRead( const paginationMessage = `(${result.matches.length} of ${result.totalMatches} matches shown. Use offset=${offset + result.matches.length} to see more.)` const endMessage = `(${result.totalMatches} match${result.totalMatches === 1 ? '' : 'es'} from ${result.totalLines} total lines)` - return appendNotifyOnExitReminder( + return appendSessionReminders( formatPtyOutput( id, session.status, @@ -138,7 +159,7 @@ function handlePlainRead( } if (result.lines.length === 0) { - return appendNotifyOnExitReminder( + return appendSessionReminders( [ ``, `(No output available - buffer is empty)`, @@ -156,7 +177,7 @@ function handlePlainRead( const paginationMessage = `(Buffer has more lines. Use offset=${result.offset + result.lines.length} to read beyond line ${result.offset + result.lines.length})` const endMessage = `(End of buffer - total ${result.totalLines} lines)` - return appendNotifyOnExitReminder( + return appendSessionReminders( formatPtyOutput( args.id, session.status, diff --git a/src/plugin/pty/tools/spawn.ts b/src/plugin/pty/tools/spawn.ts index 04b938a..f912be4 100644 --- a/src/plugin/pty/tools/spawn.ts +++ b/src/plugin/pty/tools/spawn.ts @@ -32,6 +32,12 @@ export const ptySpawn = tool({ .describe( 'If true, sends a notification to the session when the process exits (default: false)' ), + timeoutSeconds: tool.schema + .number() + .optional() + .describe( + 'Optional per-session timeout in seconds. The PTY is killed automatically when this duration elapses.' + ), }, async execute(args, ctx) { await checkCommandPermission(args.command, args.args ?? []) @@ -51,6 +57,7 @@ export const ptySpawn = tool({ parentSessionId: sessionId, parentAgent: ctx.agent, notifyOnExit: args.notifyOnExit, + timeoutSeconds: args.timeoutSeconds, }) const output = [ @@ -62,6 +69,7 @@ export const ptySpawn = tool({ `PID: ${info.pid}`, `Status: ${info.status}`, `NotifyOnExit: ${info.notifyOnExit}`, + `TimeoutSeconds: ${info.timeoutSeconds ?? 'none'}`, ``, ...(info.notifyOnExit ? ['', NOTIFY_ON_EXIT_INSTRUCTIONS] : []), ].join('\n') diff --git a/src/plugin/pty/tools/spawn.txt b/src/plugin/pty/tools/spawn.txt index 650f9f1..b36c33a 100644 --- a/src/plugin/pty/tools/spawn.txt +++ b/src/plugin/pty/tools/spawn.txt @@ -14,6 +14,9 @@ Usage: - Use `title` to give the session a human-readable name - The `description` parameter is required: a clear, concise 5-10 word description - Use `notifyOnExit` to receive a notification when the process exits (default: false) +- Use `timeoutSeconds` to auto-kill the PTY after a fixed number of seconds +- Do not set `timeoutSeconds` by default for sessions that are meant to keep running, such as dev servers, watch modes, local APIs, or REPLs. Only add a timeout for those when the user explicitly asks for one. +- Prefer setting `timeoutSeconds` for long-running commands that are still expected to finish on their own, such as builds, unit test suites, end-to-end tests, migrations, or other commands where you are waiting for a result. Returns the session info including: - `id`: Unique identifier (pty_XXXXXXXX) for use with other pty_* tools @@ -42,6 +45,8 @@ instead of polling with `pty_read`. Examples: - Start a dev server: command="npm", args=["run", "dev"], title="Dev Server" +- Start a timed dev server when explicitly requested: command="npm", args=["run", "dev"], title="Dev Server", timeoutSeconds=600 - Start a Python REPL: command="python3", title="Python REPL" - Run tests in watch mode: command="npm", args=["test", "--", "--watch"] -- Run build with notification: command="npm", args=["run", "build"], notifyOnExit=true +- Run build with notification and timeout: command="npm", args=["run", "build"], title="Build", notifyOnExit=true, timeoutSeconds=900 +- Run end-to-end tests with timeout: command="npm", args=["run", "test:e2e"], title="E2E Tests", notifyOnExit=true, timeoutSeconds=1800 diff --git a/src/plugin/pty/types.ts b/src/plugin/pty/types.ts index 8cffa0b..6d17801 100644 --- a/src/plugin/pty/types.ts +++ b/src/plugin/pty/types.ts @@ -19,6 +19,8 @@ export interface PTYSession { parentSessionId: string parentAgent?: string notifyOnExit: boolean + timeoutSeconds?: number + timedOut: boolean buffer: RingBuffer process: IPty | null } @@ -32,6 +34,8 @@ export interface PTYSessionInfo { workdir: string status: PTYStatus notifyOnExit: boolean + timeoutSeconds?: number + timedOut: boolean exitCode?: number exitSignal?: number | string pid: number @@ -49,6 +53,7 @@ export interface SpawnOptions { parentSessionId: string parentAgent?: string notifyOnExit?: boolean + timeoutSeconds?: number } export interface ReadResult { diff --git a/src/web/server/handlers/sessions.ts b/src/web/server/handlers/sessions.ts index a13de20..92af615 100644 --- a/src/web/server/handlers/sessions.ts +++ b/src/web/server/handlers/sessions.ts @@ -9,27 +9,40 @@ export function getSessions() { } export async function createSession(req: Request) { + let body: { + command: string + args?: string[] + description?: string + workdir?: string + timeoutSeconds?: number + } + + try { + body = (await req.json()) as typeof body + } catch { + return new ErrorResponse('Invalid JSON in request body', 400) + } + + if (!body.command || typeof body.command !== 'string' || body.command.trim() === '') { + return new ErrorResponse('Command is required', 400) + } + try { - const body = (await req.json()) as { - command: string - args?: string[] - description?: string - workdir?: string - } - if (!body.command || typeof body.command !== 'string' || body.command.trim() === '') { - return new ErrorResponse('Command is required', 400) - } const session = manager.spawn({ command: body.command, args: body.args || [], title: body.description, description: body.description, workdir: body.workdir, + timeoutSeconds: body.timeoutSeconds, parentSessionId: 'web-api', }) return new JsonResponse(session) - } catch { - return new ErrorResponse('Invalid JSON in request body', 400) + } catch (error) { + return new ErrorResponse( + error instanceof Error ? error.message : 'Failed to create session', + 400 + ) } } diff --git a/src/web/shared/api-client.ts b/src/web/shared/api-client.ts index edb158d..a43d6c5 100644 --- a/src/web/shared/api-client.ts +++ b/src/web/shared/api-client.ts @@ -86,6 +86,7 @@ export function createApiClient(baseUrl: string) { args?: string[] description?: string workdir?: string + timeoutSeconds?: number }) => apiFetchJson(routes.sessions, { method: 'POST', diff --git a/test/notification-manager.test.ts b/test/notification-manager.test.ts index b4b3dde..64b1163 100644 --- a/test/notification-manager.test.ts +++ b/test/notification-manager.test.ts @@ -29,6 +29,8 @@ function createSession(overrides: Partial = {}): PTYSession { parentSessionId: 'parent-session-id', parentAgent: 'agent-two', notifyOnExit: true, + timeoutSeconds: undefined, + timedOut: false, buffer, process: null, ...overrides, @@ -73,4 +75,21 @@ describe('NotificationManager', () => { 'Process failed. Use pty_read with the pattern parameter to search for errors in the output.' ) }) + + it('includes timeout context when the session timed out', async () => { + const promptAsync = mock(async (_payload: PromptPayload) => {}) + const manager = new NotificationManager() + + manager.init({ session: { promptAsync } } as unknown as OpencodeClient) + + await manager.sendExitNotification(createSession({ timeoutSeconds: 2, timedOut: true }), 0) + + expect(promptAsync).toHaveBeenCalledTimes(1) + const payload = promptAsync.mock.calls[0]![0] + const text = payload.body.parts[0]?.text ?? '' + + expect(text).toContain('TimeoutSeconds: 2') + expect(text).toContain('Timed Out: yes') + expect(text).toContain('Process reached its PTY timeout and was stopped automatically.') + }) }) diff --git a/test/pty-integration.test.ts b/test/pty-integration.test.ts index c0ac58b..580f7e2 100644 --- a/test/pty-integration.test.ts +++ b/test/pty-integration.test.ts @@ -253,5 +253,49 @@ describe('PTY Manager Integration', () => { const sessionData = await statusResponse.json() expect(sessionData.status).toBe('killed') }) + + it('should auto-kill timed sessions and mark them as timed out', async () => { + await using managedTestClient = await ManagedTestClient.create( + managedTestServer.server.getWsUrl() + ) + const title = crypto.randomUUID() + const timedOutSessionPromise = new Promise((resolve) => { + managedTestClient.sessionUpdateCallbacks.push((message) => { + if ( + message.session.title === title && + message.session.status === 'killed' && + message.session.timedOut + ) { + resolve(message) + } + }) + }) + + managedTestClient.send({ + type: 'spawn', + title, + command: 'sleep', + args: ['10'], + description: 'Timed session', + parentSessionId: managedTestServer.sessionId, + subscribe: true, + timeoutSeconds: 1, + }) + + const timedOutSession = await timedOutSessionPromise + + expect(timedOutSession.session.timeoutSeconds).toBe(1) + expect(timedOutSession.session.timedOut).toBe(true) + expect(timedOutSession.session.status).toBe('killed') + + const response = await fetch( + `${managedTestServer.server.server.url}/api/sessions/${timedOutSession.session.id}` + ) + const sessionData = (await response.json()) as PTYSessionInfo + + expect(sessionData.status).toBe('killed') + expect(sessionData.timeoutSeconds).toBe(1) + expect(sessionData.timedOut).toBe(true) + }) }) }) diff --git a/test/pty-tools.test.ts b/test/pty-tools.test.ts index cb36e5b..e15083a 100644 --- a/test/pty-tools.test.ts +++ b/test/pty-tools.test.ts @@ -20,6 +20,8 @@ describe('PTY Tools', () => { pid: 12345, status: 'running', notifyOnExit: opts.notifyOnExit ?? false, + timeoutSeconds: opts.timeoutSeconds, + timedOut: false, createdAt: new Date().toISOString(), lineCount: 0, })) @@ -54,12 +56,14 @@ describe('PTY Tools', () => { env: undefined, title: undefined, notifyOnExit: undefined, + timeoutSeconds: undefined, }) expect(result).toContain('') expect(result).toContain('ID: test-session-id') expect(result).toContain('Command: echo hello') expect(result).toContain('NotifyOnExit: false') + expect(result).toContain('TimeoutSeconds: none') expect(result).toContain('') expect(result).not.toContain('') }) @@ -83,6 +87,7 @@ describe('PTY Tools', () => { title: 'My Node Session', description: 'Running Node.js script', notifyOnExit: true, + timeoutSeconds: 60, } const result = await ptySpawn.execute(args, ctx) @@ -97,6 +102,7 @@ describe('PTY Tools', () => { parentSessionId: 'parent-session-id', parentAgent: 'test-agent', notifyOnExit: true, + timeoutSeconds: 60, }) expect(result).toContain('Title: My Node Session') @@ -105,6 +111,7 @@ describe('PTY Tools', () => { expect(result).toContain('PID: 12345') expect(result).toContain('Status: running') expect(result).toContain('NotifyOnExit: true') + expect(result).toContain('TimeoutSeconds: 60') expect(result).toContain('') expect(result).toContain( 'Completion signal for this session is the future `` message.' @@ -130,6 +137,8 @@ describe('PTY Tools', () => { workdir: '/tmp', status: 'running', notifyOnExit: false, + timeoutSeconds: undefined, + timedOut: false, pid: 12345, createdAt: new Date().toISOString(), lineCount: 2, @@ -183,6 +192,8 @@ describe('PTY Tools', () => { workdir: '/tmp', status: 'running', notifyOnExit: true, + timeoutSeconds: undefined, + timedOut: false, pid: 12345, createdAt: new Date().toISOString(), lineCount: 2, @@ -281,6 +292,8 @@ describe('PTY Tools', () => { args: ['hello'], status: 'running' as const, notifyOnExit: false, + timeoutSeconds: undefined, + timedOut: false, pid: 12345, lineCount: 10, workdir: '/tmp', diff --git a/test/types.test.ts b/test/types.test.ts index 643a4d0..f473629 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -27,6 +27,8 @@ describe('Web Types', () => { command: 'echo', status: 'running', notifyOnExit: false, + timeoutSeconds: undefined, + timedOut: false, pid: 1234, lineCount: 5, createdAt: new Date().toISOString(), @@ -63,6 +65,8 @@ describe('Web Types', () => { command: 'echo', status: 'exited', notifyOnExit: true, + timeoutSeconds: 5, + timedOut: false, exitCode: 0, pid: 1234, lineCount: 2, @@ -88,6 +92,8 @@ describe('Web Types', () => { command: 'sleep', status: 'running', notifyOnExit: false, + timeoutSeconds: undefined, + timedOut: false, pid: 5678, lineCount: 0, createdAt: new Date('2026-01-21T10:00:00.000Z').toISOString(), diff --git a/test/web-server.test.ts b/test/web-server.test.ts index 24dd4c3..991bed3 100644 --- a/test/web-server.test.ts +++ b/test/web-server.test.ts @@ -145,6 +145,22 @@ describe('Web Server', () => { expect(response.status).toBe(404) }, 200) + it('should reject invalid timeout values during session creation', async () => { + const response = await fetch(`${managedTestServer.server.server.url}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + command: 'sleep', + args: ['1'], + description: 'Invalid timeout session', + timeoutSeconds: 0, + }), + }) + + expect(response.status).toBe(400) + expect(await response.text()).toContain('timeoutSeconds must be a positive integer') + }) + it('should handle input to session', async () => { const title = crypto.randomUUID() const sessionUpdatePromise = new Promise((resolve) => {