Skip to content
Closed
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
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,12 @@ opencode

| Tool | Description |
| ----------- | --------------------------------------------------------------------------- |
| `pty_spawn` | Create a new PTY session (command, args, workdir, env, title, notifyOnExit, timeoutSeconds) |
| `pty_spawn` | Create a PTY session with automatic nudges (command, args, workdir, env, title, notifyOnExit, timeoutSeconds, nudgeIntervalSeconds) |
| `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 |
| `pty_kill` | Terminate a PTY, optionally cleanup the buffer |
| `pty_nudge` | Reschedule, recur, pause, resume, or restore automatic nudge notifications |

## Slash Commands

Expand Down Expand Up @@ -181,6 +182,28 @@ pty_read: id="pty_a1b2c3d4", limit=50
→ Shows last 50 lines of output
```

### Control automatic nudges

Agent-owned PTYs automatically nudge after 30 seconds, 1, 2, 4, 8, and 15 minutes, then every 30 minutes. The agent can override that schedule without stopping the process:

```
pty_nudge: id="pty_a1b2c3d4", action="next", seconds=1200
→ Schedules one nudge in 20 minutes, then resumes automatic mode

pty_nudge: id="pty_a1b2c3d4", action="every", seconds=1800
→ Selects a recurring 30-minute cadence

pty_nudge: id="pty_a1b2c3d4", action="pause"
→ Pauses nudges while preserving the current policy

pty_nudge: id="pty_a1b2c3d4", action="resume"
→ Restores the preserved policy
```

Nudges wait until the parent agent session is idle, report the new line count and bounded last new line since the previous nudge or manual read/write, and never affect the PTY process. Use `pty_read` when fuller output is useful.

Run `/stopnudges` while the chat is idle to pause nudges for all running PTYs owned by that chat without stopping the processes.

### Filter for errors

```
Expand Down
74 changes: 72 additions & 2 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { ptyWrite } from './plugin/pty/tools/write.ts'
import { ptyRead } from './plugin/pty/tools/read.ts'
import { ptyList } from './plugin/pty/tools/list.ts'
import { ptyKill } from './plugin/pty/tools/kill.ts'
import { ptyNudge } from './plugin/pty/tools/nudge.ts'
import { PTYServer } from './web/server/server.ts'
import open from 'open'

const ptyOpenClientCommand = 'pty-open-background-spy'
const ptyShowServerUrlCommand = 'pty-show-server-url'
const stopNudgesCommand = 'stopnudges'

export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<PluginResult> => {
initPermissions(client, directory)
Expand All @@ -19,9 +21,57 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<P

return {
'command.execute.before': async (input) => {
if (input.command !== ptyOpenClientCommand && input.command !== ptyShowServerUrlCommand) {
if (
input.command !== ptyOpenClientCommand &&
input.command !== ptyShowServerUrlCommand &&
input.command !== stopNudgesCommand
) {
return
}
if (input.command === stopNudgesCommand) {
const response = await client.session.status({ throwOnError: true })
const statuses = response.data as
| Record<string, { type: 'idle' | 'busy' | 'retry' }>
| undefined
const status = statuses?.[input.sessionID]?.type ?? 'idle'
const pausedIds =
status === 'idle' ? manager.pauseNudgesByParentSession(input.sessionID) : undefined
const paused = pausedIds?.length
if (pausedIds && pausedIds.length > 0) {
await client.session.prompt({
path: { id: input.sessionID },
body: {
noReply: true,
parts: [
{
type: 'text',
text: [
'<pty_nudge>',
`ID: ${pausedIds.join(', ')}`,
'Nudge Status: paused by user',
'Resume with: pty_nudge(id="...", action="resume")',
'</pty_nudge>',
].join('\n'),
},
],
},
})
}
await client.tui.showToast({
body: {
title: 'PTY Nudges',
message:
paused === undefined
? 'Cannot stop PTY nudges while this chat is busy.'
: paused === 0
? 'No active PTY nudges to stop.'
: `Paused nudges for ${paused} PTY ${paused === 1 ? 'process' : 'processes'}.`,
variant: paused === undefined ? 'error' : paused === 0 ? 'info' : 'success',
duration: 3000,
},
})
throw new Error('Command handled by PTY plugin')
}
if (ptyServer === undefined) {
ptyServer = await PTYServer.createServer()
}
Expand Down Expand Up @@ -50,6 +100,7 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<P
pty_read: ptyRead,
pty_list: ptyList,
pty_kill: ptyKill,
pty_nudge: ptyNudge,
},
config: async (input) => {
if (!input.command) {
Expand All @@ -63,9 +114,28 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise<P
template: `This command will show the PTY Sessions Web Interface URL.`,
description: 'Show PTY Sessions Web Interface URL',
}
input.command[stopNudgesCommand] = {
template: `Pause all PTY nudges for this chat without stopping the processes.`,
description: 'Stop PTY nudges for this chat',
}
},
event: async ({ event }) => {
if (event.type === 'session.deleted') {
if (event.type === 'message.updated') {
const { info } = event.properties
if (info.role === 'assistant') {
manager.handleParentAssistantMessage(
info.sessionID,
info.time.completed !== undefined,
info.error?.name
)
}
} else if (event.type === 'session.error') {
if (event.properties.sessionID) {
manager.handleParentSessionError(event.properties.sessionID)
}
} else if (event.type === 'session.status') {
manager.handleParentSessionStatus(event.properties.sessionID, event.properties.status.type)
} else if (event.type === 'session.deleted') {
manager.cleanupBySession(event.properties.info.id)
}
},
Expand Down
1 change: 1 addition & 0 deletions src/plugin/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export const DEFAULT_TERMINAL_COLS = 120
export const DEFAULT_TERMINAL_ROWS = 40
export const NOTIFICATION_LINE_TRUNCATE = 250
export const NOTIFICATION_TITLE_TRUNCATE = 64
export const MAX_TIMER_DELAY_SECONDS = 2_147_483
25 changes: 25 additions & 0 deletions src/plugin/pty/buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,29 @@ export interface SearchMatch {
text: string
}

export interface RawBufferDelta {
data: string
endPosition: number
truncated: boolean
}

export class RingBuffer {
private buffer: string = ''
private maxSize: number
private startPosition: number = 0
private endPosition: number = 0

constructor(maxSize: number = DEFAULT_MAX_BUFFER_SIZE) {
this.maxSize = maxSize
}

append(data: string): void {
this.buffer += data
this.endPosition += data.length
if (this.buffer.length > this.maxSize) {
const removedLength = this.buffer.length - this.maxSize
this.buffer = this.buffer.slice(-this.maxSize)
this.startPosition += removedLength
}
}

Expand All @@ -42,6 +53,15 @@ export class RingBuffer {
return this.buffer
}

readRawFrom(position: number): RawBufferDelta {
const boundedPosition = Math.min(Math.max(position, this.startPosition), this.endPosition)
return {
data: this.buffer.slice(boundedPosition - this.startPosition),
endPosition: this.endPosition,
truncated: position < this.startPosition,
}
}

search(pattern: RegExp): SearchMatch[] {
const matches: SearchMatch[] = []
const lines: string[] = this.splitBufferLines()
Expand All @@ -65,11 +85,16 @@ export class RingBuffer {
return this.buffer.length
}

get position(): number {
return this.endPosition
}

flush(): void {
// No-op in new implementation
}

clear(): void {
this.buffer = ''
this.startPosition = this.endPosition
}
}
25 changes: 25 additions & 0 deletions src/plugin/pty/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,37 @@ export function formatSessionInfo(session: PTYSessionInfo): string[] {
` Status: ${session.status}${timedOutInfo}${exitInfo}${exitSignal}`,
` PID: ${session.pid}${timeoutInfo}`,
` Lines: ${session.lineCount}`,
` ${formatNudgeSummary(session)}`,
` Workdir: ${session.workdir}`,
` Created: ${session.createdAt}`,
'',
]
}

export function formatNudgeSummary(session: PTYSessionInfo, now: number = Date.now()): string {
if (!session.nudgeEnabled) {
return 'Nudges: disabled'
}

const base =
session.nudgePolicy === 'recurring'
? `recurring every ${session.nudgeIntervalSeconds ?? 'unknown'}s`
: `automatic step ${(session.nudgeAutomaticStep ?? 0) + 1}`
const state = session.nudgePaused ? `paused, preserving ${base}` : base
const oneShot =
session.nudgeOneShotDelaySeconds === undefined
? ''
: ` | one-shot: ${session.nudgeOneShotDelaySeconds}s`
let next = 'none'
if (session.nudgeNextDueAt) {
const dueAt = new Date(session.nudgeNextDueAt).getTime()
const remainingSeconds = Math.ceil((dueAt - now) / 1000)
next =
remainingSeconds > 0 ? `${session.nudgeNextDueAt} (in ${remainingSeconds}s)` : 'due, waiting'
}
return `Nudges: ${state}${oneShot} | next: ${next}`
}

export function formatLine(line: string, lineNum: number, maxLength: number = 2000): string {
const lineNumStr = lineNum.toString().padStart(5, '0')
const truncatedLine = line.length > maxLength ? `${line.slice(0, maxLength)}...` : line
Expand Down
Loading