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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
}'
```

Expand Down Expand Up @@ -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

```
Expand Down
7 changes: 5 additions & 2 deletions src/plugin/pty/formatters.ts
Original file line number Diff line number Diff line change
@@ -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}`,
Expand Down
8 changes: 7 additions & 1 deletion src/plugin/pty/notification-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
'</pty_exited>',
'',
]

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(
Expand Down
62 changes: 62 additions & 0 deletions src/plugin/pty/session-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,64 @@ function generateId(): string {

export class SessionLifecycleManager {
private sessions: Map<string, PTYSession> = new Map()
private sessionTimeouts: Map<string, ReturnType<typeof setTimeout>> = 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)}`)

Expand All @@ -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
}
Expand Down Expand Up @@ -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()

Expand All @@ -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)
}

Expand All @@ -98,6 +156,8 @@ export class SessionLifecycleManager {
return false
}

this.clearSessionTimeout(id)

if (session.status === 'running') {
session.status = 'killing'
try {
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 25 additions & 4 deletions src/plugin/pty/tools/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ const NOTIFY_ON_EXIT_REMINDER = [
`</system_reminder>`,
].join('\n')

function buildTimeoutReminder(session: PTYSessionInfo): string {
return [
`<system_reminder>`,
`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.`,
`</system_reminder>`,
].join('\n')
}

interface ReadArgs {
id: string
offset?: number
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -91,7 +112,7 @@ function handlePatternRead(
}

if (result.matches.length === 0) {
return appendNotifyOnExitReminder(
return appendSessionReminders(
[
`<pty_output id="${id}" status="${session.status}" pattern="${pattern}">`,
`No lines matched the pattern '${pattern}'.`,
Expand All @@ -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,
Expand Down Expand Up @@ -138,7 +159,7 @@ function handlePlainRead(
}

if (result.lines.length === 0) {
return appendNotifyOnExitReminder(
return appendSessionReminders(
[
`<pty_output id="${args.id}" status="${session.status}">`,
`(No output available - buffer is empty)`,
Expand All @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/plugin/pty/tools/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [])
Expand All @@ -51,6 +57,7 @@ export const ptySpawn = tool({
parentSessionId: sessionId,
parentAgent: ctx.agent,
notifyOnExit: args.notifyOnExit,
timeoutSeconds: args.timeoutSeconds,
})

const output = [
Expand All @@ -62,6 +69,7 @@ export const ptySpawn = tool({
`PID: ${info.pid}`,
`Status: ${info.status}`,
`NotifyOnExit: ${info.notifyOnExit}`,
`TimeoutSeconds: ${info.timeoutSeconds ?? 'none'}`,
`</pty_spawned>`,
...(info.notifyOnExit ? ['', NOTIFY_ON_EXIT_INSTRUCTIONS] : []),
].join('\n')
Expand Down
7 changes: 6 additions & 1 deletion src/plugin/pty/tools/spawn.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions src/plugin/pty/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export interface PTYSession {
parentSessionId: string
parentAgent?: string
notifyOnExit: boolean
timeoutSeconds?: number
timedOut: boolean
buffer: RingBuffer
process: IPty | null
}
Expand All @@ -32,6 +34,8 @@ export interface PTYSessionInfo {
workdir: string
status: PTYStatus
notifyOnExit: boolean
timeoutSeconds?: number
timedOut: boolean
exitCode?: number
exitSignal?: number | string
pid: number
Expand All @@ -49,6 +53,7 @@ export interface SpawnOptions {
parentSessionId: string
parentAgent?: string
notifyOnExit?: boolean
timeoutSeconds?: number
}

export interface ReadResult {
Expand Down
35 changes: 24 additions & 11 deletions src/web/server/handlers/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}

Expand Down
1 change: 1 addition & 0 deletions src/web/shared/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function createApiClient(baseUrl: string) {
args?: string[]
description?: string
workdir?: string
timeoutSeconds?: number
}) =>
apiFetchJson<typeof routes.sessions, 'POST', PTYSessionInfo>(routes.sessions, {
method: 'POST',
Expand Down
Loading
Loading