From 4ffe82cfb9aca0ba53bc6ffdc951fc7dace161d1 Mon Sep 17 00:00:00 2001 From: Louis LACOSTE Date: Thu, 16 Apr 2026 16:34:25 +0200 Subject: [PATCH 1/3] feat(pty): add per-session PTY timeout support Allow PTY sessions to carry timeoutSeconds through the public spawn contract and enforce the timeout in the lifecycle manager. This stops orphaned PTY commands automatically while preserving session logs and exposing timeout context in list, read, and exit notifications. --- src/plugin/pty/formatters.ts | 7 ++- src/plugin/pty/notification-manager.ts | 8 +++- src/plugin/pty/session-lifecycle.ts | 62 ++++++++++++++++++++++++++ src/plugin/pty/tools/read.ts | 29 ++++++++++-- src/plugin/pty/tools/spawn.ts | 8 ++++ src/plugin/pty/types.ts | 5 +++ src/web/server/handlers/sessions.ts | 35 ++++++++++----- src/web/shared/api-client.ts | 1 + 8 files changed, 137 insertions(+), 18 deletions(-) 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/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', From ee45175b317a2246f467ca116a8b1abcd74bec92 Mon Sep 17 00:00:00 2001 From: Louis LACOSTE Date: Thu, 16 Apr 2026 16:34:31 +0200 Subject: [PATCH 2/3] test(pty): cover timeout handling across PTY flows Add focused coverage for timeoutSeconds plumbing, timedOut metadata, REST validation, and timeout-driven session termination. This keeps the new PTY timeout behavior locked down across tool, notification, web, and integration surfaces. --- test/notification-manager.test.ts | 19 +++++++++++++ test/pty-integration.test.ts | 44 +++++++++++++++++++++++++++++++ test/pty-tools.test.ts | 13 +++++++++ test/types.test.ts | 6 +++++ test/web-server.test.ts | 16 +++++++++++ 5 files changed, 98 insertions(+) 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) => { From 591944c95f2c4932c6d62c4bb52ba4453f85c273 Mon Sep 17 00:00:00 2001 From: Louis LACOSTE Date: Thu, 16 Apr 2026 16:34:37 +0200 Subject: [PATCH 3/3] docs(pty): document timeoutSeconds usage guidance Update the public PTY contract to show timeoutSeconds in examples and explain when agents should avoid or prefer a timeout. This keeps long-lived sessions like dev servers alive by default while encouraging timeouts for builds and finite test runs. --- README.md | 12 ++++++++++-- src/plugin/pty/tools/spawn.txt | 7 ++++++- 2 files changed, 16 insertions(+), 3 deletions(-) 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/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