diff --git a/README.md b/README.md index f23ac251..df824266 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ``` diff --git a/src/plugin.ts b/src/plugin.ts index 9f9f35c5..4da64070 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -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 => { initPermissions(client, directory) @@ -19,9 +21,57 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise

{ - 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 + | 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: [ + '', + `ID: ${pausedIds.join(', ')}`, + 'Nudge Status: paused by user', + 'Resume with: pty_nudge(id="...", action="resume")', + '', + ].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() } @@ -50,6 +100,7 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise

{ if (!input.command) { @@ -63,9 +114,28 @@ export const PTYPlugin = async ({ client, directory }: PluginContext): Promise

{ - 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) } }, diff --git a/src/plugin/constants.ts b/src/plugin/constants.ts index 86bdd845..a54b74d3 100644 --- a/src/plugin/constants.ts +++ b/src/plugin/constants.ts @@ -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 diff --git a/src/plugin/pty/buffer.ts b/src/plugin/pty/buffer.ts index 57308f04..6c41e846 100644 --- a/src/plugin/pty/buffer.ts +++ b/src/plugin/pty/buffer.ts @@ -6,9 +6,17 @@ 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 @@ -16,8 +24,11 @@ export class RingBuffer { 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 } } @@ -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() @@ -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 } } diff --git a/src/plugin/pty/formatters.ts b/src/plugin/pty/formatters.ts index a88ed01d..0316252e 100644 --- a/src/plugin/pty/formatters.ts +++ b/src/plugin/pty/formatters.ts @@ -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 diff --git a/src/plugin/pty/manager.ts b/src/plugin/pty/manager.ts index 2417622f..30564281 100644 --- a/src/plugin/pty/manager.ts +++ b/src/plugin/pty/manager.ts @@ -1,9 +1,16 @@ import type { OpencodeClient } from '@opencode-ai/sdk' import { Terminal } from 'bun-pty' import { NotificationManager } from './notification-manager.ts' +import { NudgeManager, type ParentSessionStatus } from './nudge-manager.ts' import { OutputManager } from './output-manager.ts' import { SessionLifecycleManager } from './session-lifecycle.ts' -import type { PTYSessionInfo, ReadResult, SearchResult, SpawnOptions } from './types.ts' +import type { + NudgeAction, + PTYSessionInfo, + ReadResult, + SearchResult, + SpawnOptions, +} from './types.ts' import { withSession } from './utils.ts' const proto = Terminal.prototype as unknown as { _startReadLoop?: (...args: unknown[]) => unknown } @@ -71,57 +78,85 @@ class PTYManager { private lifecycleManager = new SessionLifecycleManager() private outputManager = new OutputManager() private notificationManager = new NotificationManager() + private nudgeManager = new NudgeManager() init(client: OpencodeClient): void { this.notificationManager.init(client) + this.nudgeManager.init(client) } clearAllSessions(): void { + for (const session of this.lifecycleManager.listSessions()) { + this.nudgeManager.clearNudge(session) + } this.lifecycleManager.clearAllSessions() } spawn(opts: SpawnOptions): PTYSessionInfo { - const session = this.lifecycleManager.spawn( + const initialSession = this.lifecycleManager.spawn( opts, (session, data) => { notifyRawOutput(this.lifecycleManager.toInfo(session), data) }, async (session, exitCode) => { + this.nudgeManager.clearNudge(session) notifySessionUpdate(this.lifecycleManager.toInfo(session)) if (session?.notifyOnExit) { await this.notificationManager.sendExitNotification(session, exitCode || 0) } } ) + const rawSession = this.lifecycleManager.getSession(initialSession.id) + if (rawSession) { + this.nudgeManager.startNudge(rawSession) + } + const session = rawSession ? this.lifecycleManager.toInfo(rawSession) : initialSession notifySessionUpdate(session) return session } write(id: string, data: string): boolean { - return withSession( + const result = withSession( this.lifecycleManager, id, (session) => this.outputManager.write(session, data), false ) + if (result) { + const session = this.lifecycleManager.getSession(id) + if (session) { + this.nudgeManager.recordManualActivity(session) + } + } + return result } read(id: string, offset: number = 0, limit?: number): ReadResult | null { - return withSession( + const result = withSession( this.lifecycleManager, id, (session) => this.outputManager.read(session, offset, limit), null ) + const session = this.lifecycleManager.getSession(id) + if (result && session) { + this.nudgeManager.recordManualActivity(session) + } + return result } search(id: string, pattern: RegExp, offset: number = 0, limit?: number): SearchResult | null { - return withSession( + const result = withSession( this.lifecycleManager, id, (session) => this.outputManager.search(session, pattern, offset, limit), null ) + const session = this.lifecycleManager.getSession(id) + if (result && session) { + this.nudgeManager.recordManualActivity(session) + } + return result } list(): PTYSessionInfo[] { @@ -150,12 +185,62 @@ class PTYManager { } kill(id: string, cleanup: boolean = false): boolean { + const session = this.lifecycleManager.getSession(id) + if (session) { + this.nudgeManager.clearNudge(session) + } return this.lifecycleManager.kill(id, cleanup) } cleanupBySession(parentSessionId: string): void { + this.nudgeManager.clearByParentSession(parentSessionId) this.lifecycleManager.cleanupBySession(parentSessionId) } + + handleParentSessionStatus(parentSessionId: string, status: ParentSessionStatus): void { + this.nudgeManager.handleParentSessionStatus(parentSessionId, status) + } + + handleParentAssistantMessage( + parentSessionId: string, + completed: boolean, + errorName: string | undefined + ): void { + this.nudgeManager.handleParentAssistantMessage(parentSessionId, completed, errorName) + } + + handleParentSessionError(parentSessionId: string): void { + this.nudgeManager.handleParentSessionError(parentSessionId) + } + + pauseNudgesByParentSession(parentSessionId: string): string[] { + const paused: string[] = [] + for (const session of this.lifecycleManager.listSessions()) { + if ( + session.parentSessionId !== parentSessionId || + session.status !== 'running' || + !session.nudgeEnabled || + (session.nudgePaused && session.nudgeOneShotDelaySeconds === undefined) + ) { + continue + } + this.nudgeManager.configureNudge(session, 'pause') + notifySessionUpdate(this.lifecycleManager.toInfo(session)) + paused.push(session.id) + } + return paused + } + + configureNudge(id: string, action: NudgeAction, seconds?: number): PTYSessionInfo | null { + const session = this.lifecycleManager.getSession(id) + if (!session) { + return null + } + this.nudgeManager.configureNudge(session, action, seconds) + const info = this.lifecycleManager.toInfo(session) + notifySessionUpdate(info) + return info + } } export const manager = new PTYManager() diff --git a/src/plugin/pty/nudge-manager.ts b/src/plugin/pty/nudge-manager.ts new file mode 100644 index 00000000..5cff1f8f --- /dev/null +++ b/src/plugin/pty/nudge-manager.ts @@ -0,0 +1,795 @@ +import type { OpencodeClient } from '@opencode-ai/sdk' +import { + MAX_TIMER_DELAY_SECONDS, + NOTIFICATION_LINE_TRUNCATE, + NOTIFICATION_TITLE_TRUNCATE, +} from '../constants.ts' +import type { NudgeAction, NudgeSource, PTYSession } from './types.ts' + +export const AUTOMATIC_NUDGE_INTERVAL_SECONDS = [30, 60, 120, 240, 480, 900, 1800] as const + +const NUDGE_DELIVERY_DELAY_MS = 25 +const NUDGE_DELIVERY_RETRY_MS = 5000 + +type TimerHandle = ReturnType +export type ParentSessionStatus = 'idle' | 'busy' | 'retry' +type ParentTerminalOutcome = 'unknown' | 'clean' | 'nonclean' + +export interface NudgeClock { + now(): number + setTimeout(callback: () => void, delayMs: number): TimerHandle + clearTimeout(handle: TimerHandle): void +} + +const systemClock: NudgeClock = { + now: () => Date.now(), + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (handle) => clearTimeout(handle), +} + +interface QueuedNudge { + session: PTYSession + generation: number + source: NudgeSource +} + +interface NudgeSnapshot extends QueuedNudge { + endPosition: number + message: string +} + +interface ModelContext { + model?: { providerID: string; modelID: string } + variant?: string +} + +function sanitizeStructuredField(value: string, limit?: number): string { + const tokens: string[] = [] + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0 + if (character === '&') tokens.push('&') + else if (character === '<') tokens.push('<') + else if (character === '>') tokens.push('>') + else if (character === '\r') tokens.push('\\r') + else if (character === '\n') tokens.push('\\n') + else if (character === '\t') tokens.push('\\t') + else if (character === '\u2028') tokens.push('\\u2028') + else if (character === '\u2029') tokens.push('\\u2029') + else if (codePoint >= 32 && (codePoint < 127 || codePoint > 159)) tokens.push(character) + } + + if (limit === undefined) { + return tokens.join('') + } + + const totalLength = tokens.reduce((length, token) => length + [...token].length, 0) + if (totalLength <= limit) { + return tokens.join('') + } + + const budget = Math.max(0, limit - 3) + let result = '' + let resultLength = 0 + for (const token of tokens) { + const tokenLength = [...token].length + if (resultLength + tokenLength > budget) { + break + } + result += token + resultLength += tokenLength + } + return `${result}...` +} + +export class NudgeManager { + private client: OpencodeClient | null = null + private nudgeTimers = new Map() + private activeSessions = new Map() + private pendingNudges = new Map>() + private parentStatuses = new Map() + private parentStatusVersions = new Map() + private parentTerminalOutcomes = new Map() + private pausedNudgeDelays = new Map() + private deliveryTimers = new Map() + private flushingParents = new Set() + private submittingSessions = new Map() + private manualActivityDuringSubmission = new Map() + + constructor(private readonly clock: NudgeClock = systemClock) {} + + init(client: OpencodeClient): void { + this.client = client + } + + startNudge(session: PTYSession): void { + if (!session.nudgeEnabled || session.status !== 'running') { + return + } + this.activeSessions.set(session.id, session) + this.deferNudge(session) + } + + recordManualActivity(session: PTYSession): void { + session.nudgeLastOutputPosition = session.buffer.position + if (!session.nudgeEnabled || session.status !== 'running') { + return + } + if (this.submittingSessions.get(session.id) === session.nudgeGeneration) { + this.manualActivityDuringSubmission.set(session.id, this.clock.now()) + return + } + + if ( + session.nudgePolicy === 'automatic' && + !session.nudgePaused && + session.nudgeOneShotDelaySeconds === undefined + ) { + this.invalidateSchedule(session) + this.armNudge(session) + return + } + + session.nudgeGeneration++ + if ( + session.nudgeNextDueAt !== undefined && + session.nudgeNextDueAt <= this.clock.now() && + !this.nudgeTimers.has(session.id) + ) { + const source = + session.nudgeOneShotDelaySeconds !== undefined ? 'one-shot' : session.nudgePolicy + this.queueNudge(session, source) + } + } + + configureNudge(session: PTYSession, action: NudgeAction, seconds?: number): void { + if (!session.nudgeEnabled) { + throw new Error(`Nudges are not available for PTY session '${session.id}'.`) + } + if (session.status !== 'running') { + throw new Error(`Cannot configure nudges for PTY '${session.id}' - session is not running.`) + } + + let validatedSeconds: number | undefined + if (action === 'next' || action === 'every') { + validatedSeconds = this.requireSeconds(seconds, action) + } else { + this.rejectSeconds(seconds, action) + } + + this.invalidateSchedule(session) + switch (action) { + case 'next': + session.nudgeOneShotDelaySeconds = validatedSeconds + break + case 'every': + session.nudgePolicy = 'recurring' + session.nudgeIntervalSeconds = validatedSeconds + session.nudgePaused = false + session.nudgeOneShotDelaySeconds = undefined + break + case 'pause': + session.nudgePaused = true + session.nudgeOneShotDelaySeconds = undefined + break + case 'resume': + session.nudgePaused = false + session.nudgeOneShotDelaySeconds = undefined + break + case 'automatic': + session.nudgePolicy = 'automatic' + session.nudgeAutomaticStep = 0 + session.nudgeIntervalSeconds = undefined + session.nudgePaused = false + session.nudgeOneShotDelaySeconds = undefined + break + } + + this.activeSessions.set(session.id, session) + this.armNudge(session) + } + + clearNudge(session: PTYSession): void { + this.invalidateSchedule(session) + this.manualActivityDuringSubmission.delete(session.id) + this.pausedNudgeDelays.delete(session.id) + this.activeSessions.delete(session.id) + } + + clearByParentSession(parentSessionId: string): void { + this.clearDeliveryTimer(parentSessionId) + for (const session of [...this.activeSessions.values()]) { + if (session.parentSessionId === parentSessionId) { + this.clearNudge(session) + } + } + this.pendingNudges.delete(parentSessionId) + this.parentStatuses.delete(parentSessionId) + this.parentStatusVersions.delete(parentSessionId) + this.parentTerminalOutcomes.delete(parentSessionId) + } + + handleParentSessionStatus(parentSessionId: string, status: ParentSessionStatus): void { + const previousStatus = this.parentStatuses.get(parentSessionId) + this.parentStatusVersions.set( + parentSessionId, + (this.parentStatusVersions.get(parentSessionId) ?? 0) + 1 + ) + this.parentStatuses.set(parentSessionId, status) + if (status === 'busy' || status === 'retry') { + if (previousStatus !== 'busy' && previousStatus !== 'retry') { + this.parentTerminalOutcomes.set(parentSessionId, 'unknown') + } + this.pauseParentNudges(parentSessionId) + return + } + + if (this.parentTerminalOutcomes.get(parentSessionId) !== 'clean') { + this.pauseParentNudges(parentSessionId) + return + } + + this.resumeParentNudges(parentSessionId) + } + + handleParentAssistantMessage( + parentSessionId: string, + completed: boolean, + errorName: string | undefined + ): void { + if (errorName !== undefined) { + this.parentTerminalOutcomes.set(parentSessionId, 'nonclean') + this.pauseParentNudges(parentSessionId, false) + return + } + this.parentTerminalOutcomes.set(parentSessionId, completed ? 'clean' : 'unknown') + } + + handleParentSessionError(parentSessionId: string): void { + this.parentTerminalOutcomes.set(parentSessionId, 'nonclean') + this.pauseParentNudges(parentSessionId, false) + } + + private requireSeconds(seconds: number | undefined, action: NudgeAction): number { + if ( + seconds === undefined || + !Number.isInteger(seconds) || + seconds <= 0 || + seconds > MAX_TIMER_DELAY_SECONDS + ) { + throw new Error( + `seconds must be a positive integer no greater than ${MAX_TIMER_DELAY_SECONDS} for nudge action '${action}'` + ) + } + return seconds + } + + private rejectSeconds(seconds: number | undefined, action: NudgeAction): void { + if (seconds !== undefined) { + throw new Error(`seconds is not used by nudge action '${action}'`) + } + } + + private getAutomaticInterval(session: PTYSession, stepOffset: number = 0): number { + const step = Math.min( + session.nudgeAutomaticStep + stepOffset, + AUTOMATIC_NUDGE_INTERVAL_SECONDS.length - 1 + ) + return AUTOMATIC_NUDGE_INTERVAL_SECONDS[step] ?? 1800 + } + + private getNextSchedule( + session: PTYSession + ): { delaySeconds: number; source: NudgeSource } | null { + if (session.nudgeOneShotDelaySeconds !== undefined) { + return { delaySeconds: session.nudgeOneShotDelaySeconds, source: 'one-shot' } + } + if (session.nudgePaused) { + return null + } + if (session.nudgePolicy === 'recurring') { + if (session.nudgeIntervalSeconds === undefined) { + return null + } + return { delaySeconds: session.nudgeIntervalSeconds, source: 'recurring' } + } + return { delaySeconds: this.getAutomaticInterval(session), source: 'automatic' } + } + + private invalidateSchedule(session: PTYSession): void { + session.nudgeGeneration++ + session.nudgeNextDueAt = undefined + this.clearNudgeTimer(session.id) + this.pausedNudgeDelays.delete(session.id) + this.removePendingNudge(session) + } + + private clearNudgeTimer(id: string): void { + const timer = this.nudgeTimers.get(id) + if (!timer) { + return + } + this.clock.clearTimeout(timer.handle) + this.nudgeTimers.delete(id) + } + + private scheduleNextNudge(session: PTYSession, dueAtOverride?: number): void { + this.clearNudgeTimer(session.id) + session.nudgeNextDueAt = undefined + if (session.status !== 'running') { + return + } + + const schedule = this.getNextSchedule(session) + if (!schedule) { + return + } + + session.nudgeNextDueAt = dueAtOverride ?? this.clock.now() + schedule.delaySeconds * 1000 + const delayMs = Math.max(0, session.nudgeNextDueAt - this.clock.now()) + const handle = this.clock.setTimeout(() => { + const currentTimer = this.nudgeTimers.get(session.id) + if (currentTimer?.handle !== handle) { + return + } + this.nudgeTimers.delete(session.id) + if (session.status !== 'running') { + return + } + this.queueNudge(session, schedule.source) + }, delayMs) + + this.nudgeTimers.set(session.id, { handle, session }) + } + + private isParentCleanIdle(parentSessionId: string): boolean { + return ( + this.parentTerminalOutcomes.get(parentSessionId) === 'clean' && + this.parentStatuses.get(parentSessionId) === 'idle' + ) + } + + private deferNudge(session: PTYSession): void { + const schedule = this.getNextSchedule(session) + this.clearNudgeTimer(session.id) + session.nudgeNextDueAt = undefined + if (!schedule) { + this.pausedNudgeDelays.delete(session.id) + return + } + if (this.isParentCleanIdle(session.parentSessionId)) { + this.armNudge(session) + return + } + this.pausedNudgeDelays.set(session.id, schedule.delaySeconds * 1000) + } + + private armNudge(session: PTYSession): void { + if (!this.isParentCleanIdle(session.parentSessionId)) { + this.deferNudge(session) + return + } + const remainingDelay = this.pausedNudgeDelays.get(session.id) + this.pausedNudgeDelays.delete(session.id) + this.scheduleNextNudge( + session, + remainingDelay === undefined ? undefined : this.clock.now() + remainingDelay + ) + } + + private pauseParentNudges(parentSessionId: string, preserveRemainingDelay: boolean = true): void { + this.clearDeliveryTimer(parentSessionId) + this.pendingNudges.delete(parentSessionId) + for (const session of this.activeSessions.values()) { + if (session.parentSessionId !== parentSessionId) { + continue + } + if (preserveRemainingDelay && session.nudgeNextDueAt !== undefined) { + this.pausedNudgeDelays.set( + session.id, + Math.max(0, session.nudgeNextDueAt - this.clock.now()) + ) + } else if (!preserveRemainingDelay) { + this.pausedNudgeDelays.delete(session.id) + } + session.nudgeNextDueAt = undefined + this.clearNudgeTimer(session.id) + } + } + + private resumeParentNudges(parentSessionId: string): void { + for (const session of this.activeSessions.values()) { + if (session.parentSessionId === parentSessionId && session.status === 'running') { + this.armNudge(session) + } + } + } + + private queueNudge(session: PTYSession, source: NudgeSource): void { + let pending = this.pendingNudges.get(session.parentSessionId) + if (!pending) { + pending = new Map() + this.pendingNudges.set(session.parentSessionId, pending) + } + pending.set(session.id, { session, generation: session.nudgeGeneration, source }) + + if (this.isParentCleanIdle(session.parentSessionId)) { + this.scheduleDelivery(session.parentSessionId) + } + } + + private removePendingNudge(session: PTYSession): void { + const pending = this.pendingNudges.get(session.parentSessionId) + pending?.delete(session.id) + if (pending?.size === 0) { + this.pendingNudges.delete(session.parentSessionId) + this.clearDeliveryTimer(session.parentSessionId) + } + } + + private clearDeliveryTimer(parentSessionId: string): void { + const handle = this.deliveryTimers.get(parentSessionId) + if (!handle) { + return + } + this.clock.clearTimeout(handle) + this.deliveryTimers.delete(parentSessionId) + } + + private scheduleDelivery( + parentSessionId: string, + delayMs: number = NUDGE_DELIVERY_DELAY_MS + ): void { + if (this.deliveryTimers.has(parentSessionId) || this.flushingParents.has(parentSessionId)) { + return + } + + const handle = this.clock.setTimeout(() => { + if (this.deliveryTimers.get(parentSessionId) !== handle) { + return + } + this.deliveryTimers.delete(parentSessionId) + void this.flushParentNudges(parentSessionId) + }, delayMs) + this.deliveryTimers.set(parentSessionId, handle) + } + + private async getParentIdleState(parentSessionId: string): Promise<'idle' | 'busy' | 'unknown'> { + if (this.parentTerminalOutcomes.get(parentSessionId) !== 'clean') { + return 'busy' + } + const knownStatus = this.parentStatuses.get(parentSessionId) + if (knownStatus === 'busy' || knownStatus === 'retry') { + return 'busy' + } + if (!this.client) { + return 'unknown' + } + + const statusVersion = this.parentStatusVersions.get(parentSessionId) ?? 0 + try { + const response = await this.client.session.status({ throwOnError: true }) + const statuses = response.data as Record | undefined + const remoteStatus = statuses?.[parentSessionId]?.type + if ((this.parentStatusVersions.get(parentSessionId) ?? 0) !== statusVersion) { + const latestStatus = this.parentStatuses.get(parentSessionId) + if (latestStatus !== 'idle') { + return latestStatus ? 'busy' : 'unknown' + } + return remoteStatus === 'busy' || remoteStatus === 'retry' ? 'unknown' : 'idle' + } + const latestStatus = this.parentStatuses.get(parentSessionId) + if (latestStatus === 'busy' || latestStatus === 'retry') { + return 'busy' + } + + if (remoteStatus === 'busy' || remoteStatus === 'retry') { + this.parentStatuses.set(parentSessionId, remoteStatus) + return 'busy' + } + + this.parentStatuses.set(parentSessionId, 'idle') + return 'idle' + } catch { + if ((this.parentStatusVersions.get(parentSessionId) ?? 0) !== statusVersion) { + const latestStatus = this.parentStatuses.get(parentSessionId) + return latestStatus === 'idle' ? 'idle' : latestStatus ? 'busy' : 'unknown' + } + if (this.isParentCleanIdle(parentSessionId)) { + return 'unknown' + } + this.parentStatuses.delete(parentSessionId) + return 'unknown' + } + } + + private async flushParentNudges(parentSessionId: string): Promise { + if (this.flushingParents.has(parentSessionId)) { + return + } + this.flushingParents.add(parentSessionId) + + let detached: QueuedNudge[] = [] + let retryAfterFlush = false + try { + const idleState = await this.getParentIdleState(parentSessionId) + if (idleState !== 'idle') { + retryAfterFlush = idleState === 'unknown' + return + } + + const pending = this.pendingNudges.get(parentSessionId) + if (!pending) { + return + } + + detached = [...pending.values()].filter( + ({ session, generation }) => + session.status === 'running' && session.nudgeGeneration === generation + ) + for (const entry of detached) { + if (pending.get(entry.session.id) === entry) { + pending.delete(entry.session.id) + } + } + if (pending.size === 0) { + this.pendingNudges.delete(parentSessionId) + } + if (detached.length === 0) { + return + } + + const snapshots = detached.map((entry) => this.buildNudgeSnapshot(entry)) + if (!this.isParentCleanIdle(parentSessionId)) { + this.requeueNudges(detached) + detached = [] + return + } + + const deliveredSnapshots = await this.sendNudgeBatch(parentSessionId, snapshots) + for (const snapshot of deliveredSnapshots) { + this.completeDeliveredNudge(snapshot) + } + detached = [] + } catch { + this.rescheduleFailedAutomaticSubmissions(detached) + this.requeueNudges(detached) + retryAfterFlush = true + } finally { + this.flushingParents.delete(parentSessionId) + const pending = this.pendingNudges.get(parentSessionId) + if (pending?.size && this.isParentCleanIdle(parentSessionId)) { + this.scheduleDelivery( + parentSessionId, + retryAfterFlush ? NUDGE_DELIVERY_RETRY_MS : NUDGE_DELIVERY_DELAY_MS + ) + } + } + } + + private requeueNudges(entries: QueuedNudge[]): void { + for (const entry of entries) { + const { session, generation } = entry + if (session.status !== 'running' || session.nudgeGeneration !== generation) { + continue + } + let pending = this.pendingNudges.get(session.parentSessionId) + if (!pending) { + pending = new Map() + this.pendingNudges.set(session.parentSessionId, pending) + } + if (!pending.has(session.id)) { + pending.set(session.id, entry) + } + } + } + + private rescheduleFailedAutomaticSubmissions(entries: QueuedNudge[]): void { + for (const entry of entries) { + const manualActivityAt = this.manualActivityDuringSubmission.get(entry.session.id) + if (manualActivityAt === undefined) { + continue + } + this.manualActivityDuringSubmission.delete(entry.session.id) + if ( + entry.source !== 'automatic' || + entry.session.status !== 'running' || + entry.session.nudgeGeneration !== entry.generation + ) { + continue + } + + entry.session.nudgeGeneration++ + const dueAt = manualActivityAt + this.getAutomaticInterval(entry.session) * 1000 + this.pausedNudgeDelays.set(entry.session.id, Math.max(0, dueAt - this.clock.now())) + this.armNudge(entry.session) + } + } + + private describeTrigger(entry: QueuedNudge): string { + if (entry.source === 'one-shot') { + return `one-shot override after ${entry.session.nudgeOneShotDelaySeconds ?? 'unknown'}s` + } + if (entry.source === 'recurring') { + return `recurring every ${entry.session.nudgeIntervalSeconds ?? 'unknown'}s` + } + return `automatic step ${entry.session.nudgeAutomaticStep + 1}` + } + + private describeAfterNudge(entry: QueuedNudge): string { + const { session } = entry + if (session.nudgePaused) { + return 'paused until resumed' + } + if (session.nudgePolicy === 'recurring') { + return `recurring in ${session.nudgeIntervalSeconds ?? 'unknown'}s` + } + return `automatic in ${this.getAutomaticInterval(session, 1)}s` + } + + private buildNudgeSnapshot(entry: QueuedNudge): NudgeSnapshot { + const { session } = entry + const delta = session.buffer.readRawFrom(session.nudgeLastOutputPosition) + const elapsedSeconds = Math.max( + 0, + Math.floor((this.clock.now() - session.createdAt.getTime()) / 1000) + ) + const lastOutputActivity = + session.lastOutputAt === undefined + ? 'none yet' + : `${Math.max(0, Math.floor((this.clock.now() - session.lastOutputAt) / 1000))}s ago` + const displayTitle = session.description ?? session.title + const title = sanitizeStructuredField(displayTitle, NOTIFICATION_TITLE_TRUNCATE) + + const newOutputLines = delta.data === '' ? [] : delta.data.split('\n') + if (newOutputLines.at(-1) === '') { + newOutputLines.pop() + } + let lastNewLine = '' + for (let i = newOutputLines.length - 1; i >= 0; i--) { + const line = newOutputLines[i] + if (line !== undefined && line.trim() !== '') { + const sanitizedLine = sanitizeStructuredField(line, NOTIFICATION_LINE_TRUNCATE) + if (sanitizedLine.trim() !== '') { + lastNewLine = sanitizedLine + break + } + } + } + + return { + ...entry, + endPosition: delta.endPosition, + message: [ + '', + `ID: ${session.id}`, + `Description: ${title}`, + 'Status: process running', + `Elapsed: ${elapsedSeconds}s | Last Output Activity: ${lastOutputActivity}`, + `Nudge Trigger: ${this.describeTrigger(entry)}`, + `After This Nudge: ${this.describeAfterNudge(entry)}`, + `New Output Lines: ${delta.truncated ? `at least ${newOutputLines.length}` : newOutputLines.length}`, + `Last New Line: ${delta.truncated ? 'unavailable (new output exceeded rolling buffer)' : lastNewLine}`, + '', + ].join('\n'), + } + } + + private completeDeliveredNudge(snapshot: NudgeSnapshot): void { + const { session, generation, source } = snapshot + this.manualActivityDuringSubmission.delete(session.id) + if (session.status !== 'running' || session.nudgeGeneration !== generation) { + return + } + + session.nudgeLastOutputPosition = Math.max( + session.nudgeLastOutputPosition, + snapshot.endPosition + ) + if (source === 'one-shot') { + session.nudgeOneShotDelaySeconds = undefined + } + if (session.nudgePolicy === 'automatic' && source !== 'recurring') { + session.nudgeAutomaticStep = Math.min( + session.nudgeAutomaticStep + 1, + AUTOMATIC_NUDGE_INTERVAL_SECONDS.length - 1 + ) + } + // The prompt starts a new parent turn. A later clean completion is required before rearming. + this.parentTerminalOutcomes.set(session.parentSessionId, 'unknown') + this.deferNudge(session) + } + + private async getModelContext(parentSessionId: string): Promise { + if (!this.client) { + return {} + } + + try { + const parent = await this.client.session.get({ path: { id: parentSessionId } }) + const model = ( + parent.data as + | (typeof parent.data & { + model?: { id: string; providerID: string; variant?: string } + }) + | undefined + )?.model + if (!model) { + return {} + } + return { + model: { providerID: model.providerID, modelID: model.id }, + ...(model.variant ? { variant: model.variant } : {}), + } + } catch { + return {} + } + } + + private async sendNudgeBatch( + parentSessionId: string, + snapshots: NudgeSnapshot[] + ): Promise { + if (!this.client) { + throw new Error('Nudge manager is not initialized') + } + + let currentSnapshots = snapshots.filter( + ({ session, generation }) => + session.status === 'running' && session.nudgeGeneration === generation + ) + if (currentSnapshots.length === 0) { + return [] + } + + const modelContext = await this.getModelContext(parentSessionId) + currentSnapshots = currentSnapshots.filter( + ({ session, generation }) => + session.status === 'running' && session.nudgeGeneration === generation + ) + if (currentSnapshots.length === 0) { + return [] + } + if ((await this.getParentIdleState(parentSessionId)) !== 'idle') { + throw new Error('Parent session became busy before nudge delivery') + } + currentSnapshots = currentSnapshots.filter( + ({ session, generation }) => + session.status === 'running' && session.nudgeGeneration === generation + ) + if (currentSnapshots.length === 0) { + return [] + } + + const firstAgent = currentSnapshots[0]?.session.parentAgent + const targetAgent = currentSnapshots.every(({ session }) => session.parentAgent === firstAgent) + ? firstAgent + : undefined + const message = currentSnapshots.map((snapshot) => snapshot.message).join('\n\n') + + // The SDK has no atomic "prompt only if idle" operation. The event plus status + // snapshot is best effort; a new user turn can still race this promptAsync call. + // A batch can target only one prompt-level agent. Mixed-agent batches retain + // each originating agent in their blocks and use the parent session default. + for (const snapshot of currentSnapshots) { + this.submittingSessions.set(snapshot.session.id, snapshot.generation) + } + try { + await this.client.session.promptAsync({ + path: { id: parentSessionId }, + throwOnError: true, + body: { + parts: [{ type: 'text', text: message }], + ...(targetAgent ? { agent: targetAgent } : {}), + ...modelContext, + }, + }) + } finally { + for (const snapshot of currentSnapshots) { + if (this.submittingSessions.get(snapshot.session.id) === snapshot.generation) { + this.submittingSessions.delete(snapshot.session.id) + } + } + } + return currentSnapshots + } +} diff --git a/src/plugin/pty/session-lifecycle.ts b/src/plugin/pty/session-lifecycle.ts index ac40d87d..163f0fe7 100644 --- a/src/plugin/pty/session-lifecycle.ts +++ b/src/plugin/pty/session-lifecycle.ts @@ -1,7 +1,11 @@ import { spawn, type IPty } from 'bun-pty' import { RingBuffer } from './buffer.ts' import type { PTYSession, PTYSessionInfo, SpawnOptions } from './types.ts' -import { DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS } from '../constants.ts' +import { + DEFAULT_TERMINAL_COLS, + DEFAULT_TERMINAL_ROWS, + MAX_TIMER_DELAY_SECONDS, +} from '../constants.ts' const SESSION_ID_BYTE_LENGTH = 4 @@ -16,16 +20,16 @@ export class SessionLifecycleManager { private sessions: Map = new Map() private sessionTimeouts: Map> = new Map() - private normalizeTimeoutSeconds(timeoutSeconds: number | undefined): number | undefined { - if (timeoutSeconds === undefined) { + private normalizeSeconds(value: number | undefined, name: string): number | undefined { + if (value === undefined) { return undefined } - if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) { - throw new Error('timeoutSeconds must be a positive integer in seconds') + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer in seconds`) } - return timeoutSeconds + return value } private clearSessionTimeout(id: string): void { @@ -71,7 +75,14 @@ export class SessionLifecycleManager { const id = generateId() const args = opts.args ?? [] const workdir = opts.workdir ?? process.cwd() - const timeoutSeconds = this.normalizeTimeoutSeconds(opts.timeoutSeconds) + const timeoutSeconds = this.normalizeSeconds(opts.timeoutSeconds, 'timeoutSeconds') + const nudgeIntervalSeconds = this.normalizeSeconds( + opts.nudgeIntervalSeconds, + 'nudgeIntervalSeconds' + ) + if (nudgeIntervalSeconds !== undefined && nudgeIntervalSeconds > MAX_TIMER_DELAY_SECONDS) { + throw new Error(`nudgeIntervalSeconds must not exceed ${MAX_TIMER_DELAY_SECONDS} seconds`) + } const title = opts.title ?? (`${opts.command} ${args.join(' ')}`.trim() || `Terminal ${id.slice(-4)}`) @@ -92,6 +103,15 @@ export class SessionLifecycleManager { notifyOnExit: opts.notifyOnExit ?? false, timeoutSeconds, timedOut: false, + nudgeEnabled: opts.enableNudges ?? true, + nudgePolicy: nudgeIntervalSeconds === undefined ? 'automatic' : 'recurring', + nudgePaused: false, + nudgeAutomaticStep: 0, + nudgeIntervalSeconds, + nudgeOneShotDelaySeconds: undefined, + nudgeNextDueAt: undefined, + nudgeLastOutputPosition: 0, + nudgeGeneration: 0, buffer, process: null, // will be set } @@ -117,6 +137,7 @@ export class SessionLifecycleManager { ): void { session.process?.onData((data: string) => { session.buffer.append(data) + session.lastOutputAt = Date.now() onData(session, data) }) @@ -213,10 +234,24 @@ export class SessionLifecycleManager { notifyOnExit: session.notifyOnExit, timeoutSeconds: session.timeoutSeconds, timedOut: session.timedOut, + nudgeEnabled: session.nudgeEnabled, + nudgePolicy: session.nudgePolicy, + nudgePaused: session.nudgePaused, + nudgeAutomaticStep: session.nudgeAutomaticStep, + nudgeIntervalSeconds: session.nudgeIntervalSeconds, + nudgeOneShotDelaySeconds: session.nudgeOneShotDelaySeconds, + nudgeNextDueAt: + session.nudgeNextDueAt === undefined + ? undefined + : new Date(session.nudgeNextDueAt).toISOString(), exitCode: session.exitCode, exitSignal: session.exitSignal, pid: session.pid, createdAt: session.createdAt.toISOString(), + lastOutputAt: + session.lastOutputAt === undefined + ? undefined + : new Date(session.lastOutputAt).toISOString(), lineCount: session.buffer.length, } } diff --git a/src/plugin/pty/tools/list.txt b/src/plugin/pty/tools/list.txt index b1718707..84d1903c 100644 --- a/src/plugin/pty/tools/list.txt +++ b/src/plugin/pty/tools/list.txt @@ -17,6 +17,6 @@ Returns for each session: - `createdAt`: When the session was created Tips: -- Use the session ID with pty_read, pty_write, or pty_kill +- Use the session ID with pty_read, pty_write, pty_nudge, or pty_kill - Sessions remain in the list after exit until explicitly cleaned up with pty_kill - This allows you to compare output from multiple sessions diff --git a/src/plugin/pty/tools/nudge.ts b/src/plugin/pty/tools/nudge.ts new file mode 100644 index 00000000..fac7689f --- /dev/null +++ b/src/plugin/pty/tools/nudge.ts @@ -0,0 +1,32 @@ +import { tool } from '@opencode-ai/plugin' +import { formatNudgeSummary } from '../formatters.ts' +import { manager } from '../manager.ts' +import { buildSessionNotFoundError } from '../utils.ts' +import DESCRIPTION from './nudge.txt' + +export const ptyNudge = tool({ + description: DESCRIPTION, + args: { + id: tool.schema.string().describe('The PTY session ID (e.g., pty_a1b2c3d4)'), + action: tool.schema + .enum(['next', 'every', 'pause', 'resume', 'automatic']) + .describe('The nudge scheduling action to apply'), + seconds: tool.schema + .number() + .optional() + .describe('Positive integer delay required by next and every; unused by other actions'), + }, + async execute(args) { + const session = manager.configureNudge(args.id, args.action, args.seconds) + if (!session) { + throw buildSessionNotFoundError(args.id) + } + + return [ + '', + `ID: ${session.id}`, + formatNudgeSummary(session), + '', + ].join('\n') + }, +}) diff --git a/src/plugin/pty/tools/nudge.txt b/src/plugin/pty/tools/nudge.txt new file mode 100644 index 00000000..6c040697 --- /dev/null +++ b/src/plugin/pty/tools/nudge.txt @@ -0,0 +1,16 @@ +Controls automatic nudge notifications for a running PTY session without affecting the process. + +Every agent-owned PTY starts in automatic mode. Automatic nudges use the fallback cadence 30s, 1m, 2m, 4m, 8m, 15m, then every 30m. Use this tool when the command's output gives you better timing information or further automatic updates are unnecessary. + +Actions: +- `next` with `seconds`: Schedule one nudge after the chosen delay. This temporarily overrides the preserved automatic or recurring policy. After delivery, the preserved policy resumes. In automatic mode, the delivered one-shot counts as the next nudge and advances the automatic cadence. It also works while paused, producing one nudge before returning to paused. +- `every` with `seconds`: Select a recurring cadence and resume nudges. Any pending one-shot is cancelled. +- `pause`: Stop future nudges while preserving the current automatic stage or recurring cadence. Any pending one-shot is cancelled. The PTY process keeps running. +- `resume`: Resume the policy preserved by pause. To select a policy while resuming, use `automatic` or `every` instead. +- `automatic`: Select automatic mode, reset its cadence to 1 minute, and resume nudges. Any pending one-shot is cancelled. + +`seconds` must be a positive integer no greater than 2147483 and is accepted only by `next` and `every`. + +Manual `pty_read` and `pty_write` calls postpone automatic nudges at their current cadence. They do not change recurring or one-shot timing. All manual reads and writes still advance the nudge output cursor, so later nudges contain only newer output. + +The result confirms the effective policy and next due time. Nudge delivery waits until the parent agent session is idle and never kills, pauses, or otherwise changes the PTY process. diff --git a/src/plugin/pty/tools/spawn.ts b/src/plugin/pty/tools/spawn.ts index f912be4e..00fe1f14 100644 --- a/src/plugin/pty/tools/spawn.ts +++ b/src/plugin/pty/tools/spawn.ts @@ -1,6 +1,7 @@ import { tool } from '@opencode-ai/plugin' import { manager } from '../manager.ts' import { checkCommandPermission, checkWorkdirPermission } from '../permissions.ts' +import { formatNudgeSummary } from '../formatters.ts' import DESCRIPTION from './spawn.txt' const NOTIFY_ON_EXIT_INSTRUCTIONS = [ @@ -12,6 +13,14 @@ const NOTIFY_ON_EXIT_INSTRUCTIONS = [ ``, ].join('\n') +const NUDGE_INSTRUCTIONS = [ + ``, + `Future \`\` messages mean this PTY is still running; they are not completion signals.`, + `Use your judgment to inspect it with \`pty_read\`, manage future check-ins with \`pty_nudge\`, leave it running, or stop it.`, + `Avoid sleep-and-\`pty_read\` polling loops; nudges can be used for future check-ins instead.`, + ``, +].join('\n') + export const ptySpawn = tool({ description: DESCRIPTION, args: { @@ -38,6 +47,12 @@ export const ptySpawn = tool({ .describe( 'Optional per-session timeout in seconds. The PTY is killed automatically when this duration elapses.' ), + nudgeIntervalSeconds: tool.schema + .number() + .optional() + .describe( + 'Optional initial recurring nudge cadence in seconds. Omit for automatic cadence: 30s, 1m, 2m, 4m, 8m, 15m, then every 30m.' + ), }, async execute(args, ctx) { await checkCommandPermission(args.command, args.args ?? []) @@ -58,6 +73,7 @@ export const ptySpawn = tool({ parentAgent: ctx.agent, notifyOnExit: args.notifyOnExit, timeoutSeconds: args.timeoutSeconds, + nudgeIntervalSeconds: args.nudgeIntervalSeconds, }) const output = [ @@ -70,8 +86,10 @@ export const ptySpawn = tool({ `Status: ${info.status}`, `NotifyOnExit: ${info.notifyOnExit}`, `TimeoutSeconds: ${info.timeoutSeconds ?? 'none'}`, + formatNudgeSummary(info), ``, ...(info.notifyOnExit ? ['', NOTIFY_ON_EXIT_INSTRUCTIONS] : []), + ...(info.nudgeEnabled ? ['', NUDGE_INSTRUCTIONS] : []), ].join('\n') return output diff --git a/src/plugin/pty/tools/spawn.txt b/src/plugin/pty/tools/spawn.txt index b36c33a1..94824eab 100644 --- a/src/plugin/pty/tools/spawn.txt +++ b/src/plugin/pty/tools/spawn.txt @@ -15,6 +15,9 @@ Usage: - 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 +- Automatic status nudges are enabled by default using a 30s, 1m, 2m, 4m, 8m, 15m, then every 30m cadence +- Use `nudgeIntervalSeconds` to start with a model-selected recurring cadence instead of automatic mode +- Nudge delivery waits until the parent agent session is idle. Nudges never kill or otherwise affect the process. - 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. @@ -28,6 +31,7 @@ After spawning, use: - `pty_read` to read output from the PTY - `pty_list` to see all active PTY sessions - `pty_kill` to terminate the PTY +- `pty_nudge` to reschedule, recur, pause, or resume automatic nudges Exit Notifications: When `notifyOnExit` is true, you will receive a message when the process exits containing: @@ -43,8 +47,18 @@ instead of polling with `pty_read`. - Never use sleep plus `pty_read` loops to check completion - Use `pty_read` before exit only if you need live output now, the user explicitly asks for logs, or the exit notification reports a non-zero status and you need to investigate +Nudge Notifications: +Automatic `` messages report how long the process has been running, how much output is new since the previous nudge or manual `pty_read`/`pty_write`, and the last new line. Use `pty_read` when fuller output is useful. +- A nudge means the process is still running; it is not a completion signal +- Use your judgment to inspect it, manage future check-ins, leave it running, or stop it +- Avoid sleep-and-`pty_read` polling loops; nudges can be used for future check-ins instead +- Multiple due sessions are combined when the parent agent next becomes idle +- Use `pty_nudge` action `next` for a one-shot override, `every` for a recurring cadence, `pause` to preserve the current policy without notifications, `resume` to restore it, or `automatic` to restart the default cadence +- Manual `pty_read` and `pty_write` postpone automatic mode only. Explicit one-shot and recurring schedules are not silently changed. + Examples: - Start a dev server: command="npm", args=["run", "dev"], title="Dev Server" +- Start a dev server with a recurring cadence: command="npm", args=["run", "dev"], title="Dev Server", nudgeIntervalSeconds=600 - 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"] diff --git a/src/plugin/pty/types.ts b/src/plugin/pty/types.ts index 6d178017..e3121265 100644 --- a/src/plugin/pty/types.ts +++ b/src/plugin/pty/types.ts @@ -2,6 +2,9 @@ import type { IPty } from 'bun-pty' import type { RingBuffer } from './buffer.ts' export type PTYStatus = 'running' | 'exited' | 'killing' | 'killed' +export type NudgePolicy = 'automatic' | 'recurring' +export type NudgeSource = NudgePolicy | 'one-shot' +export type NudgeAction = 'next' | 'every' | 'pause' | 'resume' | 'automatic' export interface PTYSession { id: string @@ -16,11 +19,21 @@ export interface PTYSession { exitSignal?: number | string pid: number createdAt: Date + lastOutputAt?: number parentSessionId: string parentAgent?: string notifyOnExit: boolean timeoutSeconds?: number timedOut: boolean + nudgeEnabled: boolean + nudgePolicy: NudgePolicy + nudgePaused: boolean + nudgeAutomaticStep: number + nudgeIntervalSeconds?: number + nudgeOneShotDelaySeconds?: number + nudgeNextDueAt?: number + nudgeLastOutputPosition: number + nudgeGeneration: number buffer: RingBuffer process: IPty | null } @@ -36,10 +49,18 @@ export interface PTYSessionInfo { notifyOnExit: boolean timeoutSeconds?: number timedOut: boolean + nudgeEnabled?: boolean + nudgePolicy?: NudgePolicy + nudgePaused?: boolean + nudgeAutomaticStep?: number + nudgeIntervalSeconds?: number + nudgeOneShotDelaySeconds?: number + nudgeNextDueAt?: string exitCode?: number exitSignal?: number | string pid: number createdAt: string + lastOutputAt?: string lineCount: number } @@ -54,6 +75,8 @@ export interface SpawnOptions { parentAgent?: string notifyOnExit?: boolean timeoutSeconds?: number + nudgeIntervalSeconds?: number + enableNudges?: boolean } export interface ReadResult { diff --git a/src/web/server/handlers/sessions.ts b/src/web/server/handlers/sessions.ts index 008ce5f6..e77ab53c 100644 --- a/src/web/server/handlers/sessions.ts +++ b/src/web/server/handlers/sessions.ts @@ -43,6 +43,7 @@ export async function createSession(req: Request) { workdir: body.workdir, timeoutSeconds: body.timeoutSeconds, parentSessionId: 'web-api', + enableNudges: false, }) return new JsonResponse(session) } catch (error) { diff --git a/src/web/server/handlers/websocket.ts b/src/web/server/handlers/websocket.ts index 1ad79567..baa9cb3e 100644 --- a/src/web/server/handlers/websocket.ts +++ b/src/web/server/handlers/websocket.ts @@ -132,7 +132,19 @@ class WebSocketHandler { await checkWorkdirPermission(message.workdir) } - const sessionInfo = manager.spawn(message) + const sessionInfo = manager.spawn({ + command: message.command, + args: message.args, + workdir: message.workdir, + env: message.env, + title: message.title, + description: message.description, + parentSessionId: message.parentSessionId, + parentAgent: message.parentAgent, + notifyOnExit: message.notifyOnExit, + timeoutSeconds: message.timeoutSeconds, + enableNudges: false, + }) if (message.subscribe) { this.handleSubscribe(ws, { type: 'subscribe', sessionId: sessionInfo.id }) } diff --git a/src/web/shared/types.ts b/src/web/shared/types.ts index aaa795b5..0eb36111 100644 --- a/src/web/shared/types.ts +++ b/src/web/shared/types.ts @@ -36,10 +36,11 @@ export interface WSMessageClientSessionList extends WSMessageClient { type: 'session_list' } -export interface WSMessageClientSpawnSession extends WSMessageClient, SpawnOptions { - type: 'spawn' - subscribe?: boolean -} +export type WSMessageClientSpawnSession = WSMessageClient & + Omit & { + type: 'spawn' + subscribe?: boolean + } export interface WSMessageClientInput extends WSMessageClient { type: 'input' diff --git a/test/notification-manager.test.ts b/test/notification-manager.test.ts index b0cbb56f..071d4415 100644 --- a/test/notification-manager.test.ts +++ b/test/notification-manager.test.ts @@ -33,6 +33,15 @@ function createSession(overrides: Partial = {}): PTYSession { notifyOnExit: true, timeoutSeconds: undefined, timedOut: false, + nudgeEnabled: true, + nudgePolicy: 'automatic', + nudgePaused: false, + nudgeAutomaticStep: 0, + nudgeIntervalSeconds: undefined, + nudgeOneShotDelaySeconds: undefined, + nudgeNextDueAt: undefined, + nudgeLastOutputPosition: 0, + nudgeGeneration: 0, buffer, process: null, ...overrides, diff --git a/test/nudge-lifecycle.test.ts b/test/nudge-lifecycle.test.ts new file mode 100644 index 00000000..1fe43242 --- /dev/null +++ b/test/nudge-lifecycle.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, spyOn } from 'bun:test' +import { + manager, + registerSessionUpdateCallback, + removeSessionUpdateCallback, +} from '../src/plugin/pty/manager.ts' +import { NudgeManager } from '../src/plugin/pty/nudge-manager.ts' + +describe('nudge lifecycle wiring', () => { + it('clears nudge state when a session exits naturally', async () => { + const clearNudge = spyOn(NudgeManager.prototype, 'clearNudge') + const title = crypto.randomUUID() + const exited = new Promise((resolve) => { + const callback = (session: { title: string; status: string }) => { + if (session.title === title && session.status === 'exited') { + removeSessionUpdateCallback(callback) + resolve() + } + } + registerSessionUpdateCallback(callback) + }) + + const session = manager.spawn({ + command: 'echo', + args: ['done'], + title, + description: 'Natural exit nudge cleanup', + parentSessionId: 'nudge-lifecycle-test', + nudgeIntervalSeconds: 600, + }) + + await exited + expect(clearNudge.mock.calls.some(([value]) => value.id === session.id)).toBe(true) + manager.kill(session.id, true) + clearNudge.mockRestore() + }) + + it('clears nudge state when a session is killed', () => { + const clearNudge = spyOn(NudgeManager.prototype, 'clearNudge') + const session = manager.spawn({ + command: 'sleep', + args: ['60'], + description: 'Explicit kill nudge cleanup', + parentSessionId: 'nudge-lifecycle-test', + nudgeIntervalSeconds: 600, + }) + + expect(manager.kill(session.id, true)).toBe(true) + expect(clearNudge.mock.calls.some(([value]) => value.id === session.id)).toBe(true) + clearNudge.mockRestore() + }) + + it('routes manager reads and writes through manual nudge activity', () => { + const recordManualActivity = spyOn(NudgeManager.prototype, 'recordManualActivity') + const session = manager.spawn({ + command: 'sleep', + args: ['60'], + description: 'Manual activity nudge wiring', + parentSessionId: 'nudge-lifecycle-test', + }) + + manager.read(session.id) + manager.write(session.id, '') + + expect( + recordManualActivity.mock.calls.filter(([value]) => value.id === session.id) + ).toHaveLength(2) + manager.kill(session.id, true) + recordManualActivity.mockRestore() + }) + + it('pauses nudges for running PTYs owned by one parent session', () => { + const parentSessionId = crypto.randomUUID() + const session = manager.spawn({ + command: 'sleep', + args: ['60'], + description: 'Session-wide nudge pause wiring', + parentSessionId, + }) + + expect(manager.pauseNudgesByParentSession(parentSessionId)).toEqual([session.id]) + expect(manager.get(session.id)?.nudgePaused).toBe(true) + expect(manager.pauseNudgesByParentSession(parentSessionId)).toEqual([]) + + manager.kill(session.id, true) + }) + + it('rejects spawn intervals beyond the timer-safe range', () => { + expect(() => + manager.spawn({ + command: 'echo', + args: ['never spawned'], + description: 'Invalid nudge interval', + parentSessionId: 'nudge-lifecycle-test', + nudgeIntervalSeconds: 2_147_484, + }) + ).toThrow('nudgeIntervalSeconds must not exceed 2147483 seconds') + }) +}) diff --git a/test/nudge-manager.test.ts b/test/nudge-manager.test.ts new file mode 100644 index 00000000..bb68567c --- /dev/null +++ b/test/nudge-manager.test.ts @@ -0,0 +1,823 @@ +import { describe, expect, it, mock } from 'bun:test' +import type { OpencodeClient } from '@opencode-ai/sdk' +import { RingBuffer } from '../src/plugin/pty/buffer.ts' +import { + AUTOMATIC_NUDGE_INTERVAL_SECONDS, + NudgeManager as BaseNudgeManager, + type NudgeClock, +} from '../src/plugin/pty/nudge-manager.ts' +import type { PTYSession } from '../src/plugin/pty/types.ts' + +type TimerHandle = ReturnType + +class NudgeManager extends BaseNudgeManager { + override init(client: OpencodeClient): void { + super.init(client) + this.handleParentAssistantMessage('parent-session', true, undefined) + this.handleParentSessionStatus('parent-session', 'idle') + } +} + +class FakeClock implements NudgeClock { + private currentTime = 0 + private nextId = 1 + private tasks = new Map void }>() + readonly delayHistory: number[] = [] + + now(): number { + return this.currentTime + } + + setTimeout(callback: () => void, delayMs: number): TimerHandle { + const id = this.nextId++ + this.tasks.set(id, { dueAt: this.currentTime + delayMs, callback }) + this.delayHistory.push(delayMs) + return id as unknown as TimerHandle + } + + clearTimeout(handle: TimerHandle): void { + this.tasks.delete(handle as unknown as number) + } + + async advanceBy(milliseconds: number): Promise { + const targetTime = this.currentTime + milliseconds + while (true) { + const next = [...this.tasks.entries()] + .filter(([, task]) => task.dueAt <= targetTime) + .sort((left, right) => left[1].dueAt - right[1].dueAt || left[0] - right[0])[0] + if (!next) { + break + } + + const [id, task] = next + this.tasks.delete(id) + this.currentTime = task.dueAt + task.callback() + await settleAsyncWork() + } + + this.currentTime = targetTime + await settleAsyncWork() + } + + get pendingTaskCount(): number { + return this.tasks.size + } +} + +async function settleAsyncWork(): Promise { + for (let i = 0; i < 8; i++) { + await Promise.resolve() + } +} + +function createSession(id: string, overrides: Partial = {}): PTYSession { + return { + id, + title: `Session ${id}`, + description: `Description ${id}`, + command: 'sleep', + args: ['60'], + workdir: '/tmp', + status: 'running', + pid: 1234, + createdAt: new Date(0), + lastOutputAt: undefined, + parentSessionId: 'parent-session', + parentAgent: 'test-agent', + notifyOnExit: false, + timeoutSeconds: undefined, + timedOut: false, + nudgeEnabled: true, + nudgePolicy: 'automatic', + nudgePaused: false, + nudgeAutomaticStep: 0, + nudgeIntervalSeconds: undefined, + nudgeOneShotDelaySeconds: undefined, + nudgeNextDueAt: undefined, + nudgeLastOutputPosition: 0, + nudgeGeneration: 0, + buffer: new RingBuffer(), + process: null, + ...overrides, + } +} + +function createClient(statuses: Record = {}) { + const promptAsync = mock(async (_payload: unknown) => {}) + const status = mock(async () => ({ data: statuses })) + const get = mock(async () => ({ data: {} })) + return { + client: { session: { promptAsync, status, get } } as unknown as OpencodeClient, + promptAsync, + status, + statuses, + } +} + +function getPromptText(promptAsync: ReturnType, index: number): string { + const payload = promptAsync.mock.calls[index]?.[0] as + | { body?: { parts?: Array<{ text?: string }> } } + | undefined + return payload?.body?.parts?.[0]?.text ?? '' +} + +function completeParentTurn(manager: BaseNudgeManager, session: PTYSession): void { + manager.handleParentAssistantMessage(session.parentSessionId, true, undefined) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') +} + +describe('NudgeManager', () => { + it('uses the automatic cadence and remains at 30 minutes at the final step', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_automatic') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + for (const [index, interval] of AUTOMATIC_NUDGE_INTERVAL_SECONDS.entries()) { + await clock.advanceBy(interval * 1000 + 25) + if (index < AUTOMATIC_NUDGE_INTERVAL_SECONDS.length - 1) { + completeParentTurn(manager, session) + } + } + + expect(promptAsync).toHaveBeenCalledTimes(AUTOMATIC_NUDGE_INTERVAL_SECONDS.length) + expect(clock.delayHistory.filter((delay) => delay >= 1000)).toEqual( + AUTOMATIC_NUDGE_INTERVAL_SECONDS.map((seconds) => seconds * 1000) + ) + expect(session.nudgeAutomaticStep).toBe(6) + expect(session.nudgeNextDueAt).toBeUndefined() + completeParentTurn(manager, session) + expect(session.nudgeNextDueAt).toBe(clock.now() + 1_800_000) + expect(getPromptText(promptAsync, 0)).toContain('After This Nudge: automatic in 60s') + }) + + it('uses a spawn-selected recurring cadence without advancing automatic state', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_recurring', { + nudgePolicy: 'recurring', + nudgeIntervalSeconds: 300, + }) + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(300_025) + + expect(promptAsync).toHaveBeenCalledTimes(1) + expect(session.nudgeAutomaticStep).toBe(0) + expect(session.nudgeNextDueAt).toBeUndefined() + completeParentTurn(manager, session) + expect(session.nudgeNextDueAt).toBe(clock.now() + 300_000) + expect(getPromptText(promptAsync, 0)).toContain('Nudge Trigger: recurring every 300s') + }) + + it('applies a one-shot override and then resumes automatic mode', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_one_shot') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + manager.configureNudge(session, 'next', 600) + expect(session.nudgeOneShotDelaySeconds).toBe(600) + expect(session.nudgeNextDueAt).toBe(600_000) + await clock.advanceBy(600_025) + + expect(promptAsync).toHaveBeenCalledTimes(1) + expect(session.nudgeOneShotDelaySeconds).toBeUndefined() + expect(session.nudgeAutomaticStep).toBe(1) + completeParentTurn(manager, session) + expect(session.nudgeNextDueAt).toBe(clock.now() + 60_000) + expect(getPromptText(promptAsync, 0)).toContain('Nudge Trigger: one-shot override after 600s') + }) + + it('preserves recurring policy across pause and resume', () => { + const clock = new FakeClock() + const { client } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_pause', { + nudgePolicy: 'recurring', + nudgeIntervalSeconds: 900, + }) + manager.init(client) + manager.startNudge(session) + + manager.configureNudge(session, 'pause') + expect(session.nudgePaused).toBe(true) + expect(session.nudgePolicy).toBe('recurring') + expect(session.nudgeIntervalSeconds).toBe(900) + expect(session.nudgeNextDueAt).toBeUndefined() + + manager.configureNudge(session, 'resume') + expect(session.nudgePaused).toBe(false) + expect(session.nudgePolicy).toBe('recurring') + expect(session.nudgeNextDueAt).toBe(clock.now() + 900_000) + }) + + it('allows one nudge while paused and returns to paused', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_paused_once') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + manager.configureNudge(session, 'pause') + manager.configureNudge(session, 'next', 30) + + await clock.advanceBy(30_025) + + expect(promptAsync).toHaveBeenCalledTimes(1) + expect(session.nudgePaused).toBe(true) + expect(session.nudgeOneShotDelaySeconds).toBeUndefined() + expect(session.nudgeNextDueAt).toBeUndefined() + expect(getPromptText(promptAsync, 0)).toContain('After This Nudge: paused until resumed') + }) + + it('lets every and automatic select a policy while resuming', () => { + const clock = new FakeClock() + const { client } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_select_policy') + manager.init(client) + manager.startNudge(session) + manager.configureNudge(session, 'pause') + + manager.configureNudge(session, 'every', 1200) + expect(session.nudgePaused).toBe(false) + expect(session.nudgePolicy).toBe('recurring') + expect(session.nudgeNextDueAt).toBe(clock.now() + 1_200_000) + + manager.configureNudge(session, 'pause') + manager.configureNudge(session, 'automatic') + expect(session.nudgePaused).toBe(false) + expect(session.nudgePolicy).toBe('automatic') + expect(session.nudgeAutomaticStep).toBe(0) + expect(session.nudgeNextDueAt).toBe(clock.now() + 30_000) + }) + + it('postpones automatic mode but leaves explicit schedules unchanged on manual activity', async () => { + const clock = new FakeClock() + const { client } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_manual') + manager.init(client) + manager.startNudge(session) + + await clock.advanceBy(15_000) + session.buffer.append('checked\n') + manager.recordManualActivity(session) + expect(session.nudgeNextDueAt).toBe(clock.now() + 30_000) + expect(session.nudgeLastOutputPosition).toBe(session.buffer.position) + + manager.configureNudge(session, 'every', 300) + const recurringDueAt = session.nudgeNextDueAt + await clock.advanceBy(30_000) + session.buffer.append('checked recurring\n') + manager.recordManualActivity(session) + expect(session.nudgeNextDueAt).toBe(recurringDueAt) + expect(session.nudgeLastOutputPosition).toBe(session.buffer.position) + + manager.configureNudge(session, 'next', 90) + const oneShotDueAt = session.nudgeNextDueAt + await clock.advanceBy(30_000) + manager.recordManualActivity(session) + expect(session.nudgeNextDueAt).toBe(oneShotDueAt) + }) + + it('summarizes only output added since the previous nudge or manual activity', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_delta', { lastOutputAt: 27_000 }) + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + session.buffer.append('first hidden line\nfirst visible line\n') + manager.startNudge(session) + + await clock.advanceBy(60_025) + const firstNudge = getPromptText(promptAsync, 0) + expect(firstNudge.startsWith('')).toBe(true) + expect(firstNudge).toContain('Status: process running') + expect(firstNudge).toContain('Elapsed: 30s | Last Output Activity: 3s ago') + expect(firstNudge).toContain('New Output Lines: 2') + expect(firstNudge).toContain('Last New Line: first visible line') + expect(firstNudge).not.toContain('first hidden line') + expect(firstNudge).not.toContain('This is an automatic status update') + + session.buffer.append('manually observed\n') + manager.recordManualActivity(session) + session.buffer.append('after observation hidden\nafter observation visible\n') + completeParentTurn(manager, session) + await clock.advanceBy(120_025) + const secondNudge = getPromptText(promptAsync, 1) + expect(secondNudge).toContain('New Output Lines: 2') + expect(secondNudge).toContain('Last New Line: after observation visible') + expect(secondNudge).not.toContain('after observation hidden') + expect(secondNudge).not.toContain('manually observed') + expect(secondNudge).not.toContain('first visible line') + }) + + it('truncates the last new line to the notification limit', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_last_line_limit') + const longLine = '🦀'.repeat(300) + session.buffer.append(`${longLine}\n`) + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(60_025) + + const text = getPromptText(promptAsync, 0) + expect(text).toContain('Last Output Activity: none yet') + expect(text).toContain('New Output Lines: 1') + expect(text).toContain(`Last New Line: ${'🦀'.repeat(247)}...`) + expect(text).not.toContain(longLine) + }) + + it('keeps structured fields inside the nudge block', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_structured_fields', { + description: 'build\n\u2028next', + parentAgent: 'agent\u2029next', + }) + session.buffer.append('output \r\u2028next\n') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(60_025) + + const text = getPromptText(promptAsync, 0) + expect(text.match(/<\/pty_nudge>/g)).toHaveLength(1) + expect(text).toContain('Description: build\\n</pty_nudge>\\u2028next') + expect(text).toContain('Last New Line: output </pty_nudge>\\r\\u2028next') + expect(text).not.toContain('Agent:') + expect(text).not.toContain('\u2028') + expect(text).not.toContain('\u2029') + }) + + it('ignores a final new line that becomes empty after sanitizing controls', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_control_only_line') + session.buffer.append('printable line\n\u0001\n') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(60_025) + + expect(getPromptText(promptAsync, 0)).toContain('Last New Line: printable line') + }) + + it('marks the new line count as a lower bound after buffer eviction', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_evicted_delta', { buffer: new RingBuffer(10) }) + session.buffer.append('line1\nline2\nline3') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(60_025) + + expect(getPromptText(promptAsync, 0)).toContain('New Output Lines: at least 2') + expect(getPromptText(promptAsync, 0)).toContain( + 'Last New Line: unavailable (new output exceeded rolling buffer)' + ) + }) + + it('pauses the timer while busy and resumes it after a clean idle completion', async () => { + const clock = new FakeClock() + const { client, promptAsync, statuses } = createClient({ + 'parent-session': { type: 'busy' }, + }) + const manager = new NudgeManager(clock) + const session = createSession('pty_idle_gate') + manager.init(client) + manager.startNudge(session) + + await clock.advanceBy(15_000) + manager.handleParentSessionStatus(session.parentSessionId, 'busy') + expect(session.nudgeNextDueAt).toBeUndefined() + await clock.advanceBy(300_000) + expect(promptAsync).not.toHaveBeenCalled() + + delete statuses[session.parentSessionId] + completeParentTurn(manager, session) + expect(session.nudgeNextDueAt).toBe(clock.now() + 15_000) + await clock.advanceBy(14_999) + expect(promptAsync).not.toHaveBeenCalled() + await clock.advanceBy(26) + expect(promptAsync).toHaveBeenCalledTimes(1) + expect(session.nudgeAutomaticStep).toBe(1) + expect(session.nudgeNextDueAt).toBeUndefined() + completeParentTurn(manager, session) + expect(session.nudgeNextDueAt).toBe(clock.now() + 60_000) + }) + + it('does not arm a nudge until a parent turn has completed cleanly', () => { + const clock = new FakeClock() + const { client } = createClient() + const manager = new BaseNudgeManager(clock) + const session = createSession('pty_clean_idle') + manager.init(client) + manager.startNudge(session) + + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + expect(clock.pendingTaskCount).toBe(0) + + completeParentTurn(manager, session) + expect(session.nudgeNextDueAt).toBe(clock.now() + 30_000) + }) + + it('preserves clean completion across the final repeated busy status', () => { + const clock = new FakeClock() + const { client } = createClient() + const manager = new BaseNudgeManager(clock) + const session = createSession('pty_final_busy') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'busy') + manager.startNudge(session) + + manager.handleParentAssistantMessage(session.parentSessionId, true, undefined) + manager.handleParentSessionStatus(session.parentSessionId, 'busy') + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + + expect(session.nudgeNextDueAt).toBe(clock.now() + 30_000) + }) + + it('requires a new clean completion after idle transitions to busy', () => { + const clock = new FakeClock() + const { client } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_new_busy_turn') + manager.init(client) + manager.startNudge(session) + + manager.handleParentSessionStatus(session.parentSessionId, 'busy') + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + + expect(session.nudgeNextDueAt).toBeUndefined() + expect(clock.pendingTaskCount).toBe(0) + }) + + it('suppresses pending nudges after a non-clean parent outcome', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_nonclean') + manager.init(client) + manager.startNudge(session) + + await clock.advanceBy(30_000) + manager.handleParentAssistantMessage(session.parentSessionId, true, 'MessageAbortedError') + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + await clock.advanceBy(25) + + expect(promptAsync).not.toHaveBeenCalled() + expect(session.nudgeNextDueAt).toBeUndefined() + + completeParentTurn(manager, session) + expect(session.nudgeNextDueAt).toBe(clock.now() + 30_000) + }) + + it('allows recurring intervals longer than the automatic 30-minute cap', () => { + const clock = new FakeClock() + const { client } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_long_recurring') + manager.init(client) + manager.startNudge(session) + + manager.configureNudge(session, 'every', 7_200) + expect(session.nudgeNextDueAt).toBe(clock.now() + 7_200_000) + }) + + it('batches sessions due at the same idle moment into one prompt', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const first = createSession('pty_first') + const second = createSession('pty_second') + manager.init(client) + manager.handleParentSessionStatus(first.parentSessionId, 'idle') + manager.startNudge(first) + manager.startNudge(second) + + await clock.advanceBy(60_025) + + expect(promptAsync).toHaveBeenCalledTimes(1) + const text = getPromptText(promptAsync, 0) + expect(text.match(//g)).toHaveLength(2) + expect(text.startsWith('')).toBe(true) + expect(text).not.toContain('This is an automatic status update') + expect(text).toContain('ID: pty_first') + expect(text).toContain('ID: pty_second') + }) + + it('omits prompt-level agent targeting for mixed-agent batches', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const first = createSession('pty_agent_one', { parentAgent: 'agent-one' }) + const second = createSession('pty_agent_two', { parentAgent: 'agent-two' }) + manager.init(client) + manager.handleParentSessionStatus(first.parentSessionId, 'idle') + manager.startNudge(first) + manager.startNudge(second) + + await clock.advanceBy(60_025) + + const payload = promptAsync.mock.calls[0]?.[0] as + | { body?: { agent?: string; parts?: Array<{ text?: string }> } } + | undefined + expect(payload?.body && Object.hasOwn(payload.body, 'agent')).toBe(false) + expect(payload?.body?.parts?.[0]?.text).not.toContain('Agent:') + }) + + it('rechecks conflicting idle events and busy status responses', async () => { + const clock = new FakeClock() + const { client, promptAsync, status } = createClient() + let resolveStatus: ((value: { data: Record }) => void) | undefined + status.mockImplementationOnce( + async () => + await new Promise<{ data: Record }>((resolve) => { + resolveStatus = resolve + }) + ) + const manager = new NudgeManager(clock) + const session = createSession('pty_status_race') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(60_025) + manager.handleParentSessionStatus(session.parentSessionId, 'busy') + completeParentTurn(manager, session) + resolveStatus?.({ data: { [session.parentSessionId]: { type: 'busy' } } }) + await settleAsyncWork() + + expect(promptAsync).not.toHaveBeenCalled() + await clock.advanceBy(5_000) + expect(promptAsync).toHaveBeenCalledTimes(1) + }) + + it('drops a snapshot invalidated during the final status check', async () => { + const clock = new FakeClock() + const { client, promptAsync, status } = createClient() + let resolveFinalStatus: ((value: { data: Record }) => void) | undefined + status.mockImplementationOnce(async () => ({ data: {} })) + status.mockImplementationOnce( + async () => + await new Promise<{ data: Record }>((resolve) => { + resolveFinalStatus = resolve + }) + ) + const manager = new NudgeManager(clock) + const session = createSession('pty_final_check_race') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(60_025) + manager.configureNudge(session, 'pause') + resolveFinalStatus?.({ data: {} }) + await settleAsyncWork() + + expect(promptAsync).not.toHaveBeenCalled() + expect(session.nudgePaused).toBe(true) + }) + + it('rebuilds a due recurring snapshot after manual activity', async () => { + const clock = new FakeClock() + const { client, promptAsync, status } = createClient() + let resolveFinalStatus: ((value: { data: Record }) => void) | undefined + status.mockImplementationOnce(async () => ({ data: {} })) + status.mockImplementationOnce( + async () => + await new Promise<{ data: Record }>((resolve) => { + resolveFinalStatus = resolve + }) + ) + const manager = new NudgeManager(clock) + const session = createSession('pty_manual_snapshot', { + nudgePolicy: 'recurring', + nudgeIntervalSeconds: 60, + }) + session.buffer.append('already consumed\n') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(60_025) + manager.recordManualActivity(session) + resolveFinalStatus?.({ data: {} }) + await settleAsyncWork() + await clock.advanceBy(25) + + expect(promptAsync).toHaveBeenCalledTimes(1) + expect(getPromptText(promptAsync, 0)).toContain('New Output Lines: 0') + expect(getPromptText(promptAsync, 0)).toContain('Last New Line: ') + expect(getPromptText(promptAsync, 0)).not.toContain('already consumed') + expect(session.nudgeNextDueAt).toBeUndefined() + }) + + it('does not duplicate a nudge after prompt submission begins', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + let resolvePrompt: (() => void) | undefined + let markPromptStarted: (() => void) | undefined + const promptStarted = new Promise((resolve) => { + markPromptStarted = resolve + }) + promptAsync.mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolvePrompt = resolve + markPromptStarted?.() + }) + ) + const manager = new NudgeManager(clock) + const session = createSession('pty_submitting', { + nudgePolicy: 'recurring', + nudgeIntervalSeconds: 60, + }) + session.buffer.append('submitted output\n') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(60_025) + await promptStarted + const generation = session.nudgeGeneration + manager.recordManualActivity(session) + expect(session.nudgeGeneration).toBe(generation) + resolvePrompt?.() + await settleAsyncWork() + await clock.advanceBy(25) + + expect(promptAsync).toHaveBeenCalledTimes(1) + expect(session.nudgeNextDueAt).toBeUndefined() + }) + + it('postpones automatic mode from in-flight manual activity when submission fails', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + let rejectPrompt: ((error: Error) => void) | undefined + let markPromptStarted: (() => void) | undefined + const promptStarted = new Promise((resolve) => { + markPromptStarted = resolve + }) + promptAsync.mockImplementationOnce( + async () => + await new Promise((_resolve, reject) => { + rejectPrompt = reject + markPromptStarted?.() + }) + ) + const manager = new NudgeManager(clock) + const session = createSession('pty_failed_submission') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(60_025) + await promptStarted + manager.recordManualActivity(session) + rejectPrompt?.(new Error('Prompt rejected')) + await settleAsyncWork() + + expect(promptAsync).toHaveBeenCalledTimes(1) + expect(session.nudgeAutomaticStep).toBe(0) + expect(session.nudgeNextDueAt).toBe(90_025) + await clock.advanceBy(29_999) + expect(promptAsync).toHaveBeenCalledTimes(1) + }) + + it('does not let an old submission shadow a newly configured generation', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + let resolvePrompt: (() => void) | undefined + let markPromptStarted: (() => void) | undefined + const promptStarted = new Promise((resolve) => { + markPromptStarted = resolve + }) + promptAsync.mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolvePrompt = resolve + markPromptStarted?.() + }) + ) + const manager = new NudgeManager(clock) + const session = createSession('pty_new_generation') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(60_025) + await promptStarted + manager.configureNudge(session, 'automatic') + await clock.advanceBy(10_000) + manager.recordManualActivity(session) + expect(session.nudgeNextDueAt).toBe(100_025) + + resolvePrompt?.() + await settleAsyncWork() + expect(session.nudgeNextDueAt).toBe(100_025) + expect(promptAsync).toHaveBeenCalledTimes(1) + }) + + it('retries status and prompt failures without advancing policy or cursor', async () => { + const clock = new FakeClock() + const { client, promptAsync, status } = createClient() + status.mockImplementationOnce(async () => { + throw new Error('Status API unavailable') + }) + promptAsync.mockImplementationOnce(async () => { + throw new Error('Prompt rejected') + }) + const manager = new NudgeManager(clock) + const session = createSession('pty_retry') + session.buffer.append('must remain pending\n') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(30_025) + expect(promptAsync).not.toHaveBeenCalled() + await clock.advanceBy(5_000) + expect(promptAsync).toHaveBeenCalledTimes(1) + expect(session.nudgeAutomaticStep).toBe(0) + expect(session.nudgeLastOutputPosition).toBe(0) + + await clock.advanceBy(5_000) + expect(promptAsync).toHaveBeenCalledTimes(2) + expect(session.nudgeAutomaticStep).toBe(1) + expect(session.nudgeLastOutputPosition).toBe(session.buffer.position) + }) + + it('validates control arguments without destroying the existing schedule', () => { + const clock = new FakeClock() + const { client } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_validation') + manager.init(client) + manager.startNudge(session) + const dueAt = session.nudgeNextDueAt + + expect(() => manager.configureNudge(session, 'next')).toThrow('seconds must be') + expect(() => manager.configureNudge(session, 'pause', 10)).toThrow('seconds is not used') + expect(() => manager.configureNudge(session, 'every', 2_147_484)).toThrow( + 'no greater than 2147483' + ) + expect(session.nudgeNextDueAt).toBe(dueAt) + }) + + it('does not schedule nudges for non-agent web sessions', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_disabled', { nudgeEnabled: false }) + manager.init(client) + manager.startNudge(session) + + expect(clock.pendingTaskCount).toBe(0) + await clock.advanceBy(1_000_000) + expect(promptAsync).not.toHaveBeenCalled() + }) + + it('clears the timer and pending delivery when a session ends', async () => { + const clock = new FakeClock() + const { client, promptAsync } = createClient() + const manager = new NudgeManager(clock) + const session = createSession('pty_cleanup') + manager.init(client) + manager.handleParentSessionStatus(session.parentSessionId, 'idle') + manager.startNudge(session) + + await clock.advanceBy(30_000) + session.status = 'killed' + manager.clearNudge(session) + + expect(clock.pendingTaskCount).toBe(0) + expect(promptAsync).not.toHaveBeenCalled() + }) +}) diff --git a/test/pty-integration.test.ts b/test/pty-integration.test.ts index 580f7e29..d4775457 100644 --- a/test/pty-integration.test.ts +++ b/test/pty-integration.test.ts @@ -158,6 +158,7 @@ describe('PTY Manager Integration', () => { expect(testSession.status).toBeDefined() expect(typeof testSession.pid).toBe('number') expect(testSession.lineCount).toBeGreaterThan(0) + expect(testSession.nudgeEnabled).toBe(false) expect(outputTotal).toContain('test') }) diff --git a/test/pty-tools.test.ts b/test/pty-tools.test.ts index e15083ac..16cbe16f 100644 --- a/test/pty-tools.test.ts +++ b/test/pty-tools.test.ts @@ -2,9 +2,18 @@ import { describe, it, expect, beforeEach, mock, spyOn, afterAll } from 'bun:tes import { ptySpawn } from '../src/plugin/pty/tools/spawn.ts' import { ptyRead } from '../src/plugin/pty/tools/read.ts' import { ptyList } from '../src/plugin/pty/tools/list.ts' +import { ptyNudge } from '../src/plugin/pty/tools/nudge.ts' import { RingBuffer } from '../src/plugin/pty/buffer.ts' import { manager } from '../src/plugin/pty/manager.ts' +const EXPECTED_NUDGE_REMINDER = [ + '', + 'Future `` messages mean this PTY is still running; they are not completion signals.', + 'Use your judgment to inspect it with `pty_read`, manage future check-ins with `pty_nudge`, leave it running, or stop it.', + 'Avoid sleep-and-`pty_read` polling loops; nudges can be used for future check-ins instead.', + '', +].join('\n') + describe('PTY Tools', () => { afterAll(() => { mock.restore() @@ -22,6 +31,12 @@ describe('PTY Tools', () => { notifyOnExit: opts.notifyOnExit ?? false, timeoutSeconds: opts.timeoutSeconds, timedOut: false, + nudgeEnabled: true, + nudgePolicy: opts.nudgeIntervalSeconds === undefined ? 'automatic' : 'recurring', + nudgePaused: false, + nudgeAutomaticStep: 0, + nudgeIntervalSeconds: opts.nudgeIntervalSeconds, + nudgeNextDueAt: new Date(Date.now() + 60_000).toISOString(), createdAt: new Date().toISOString(), lineCount: 0, })) @@ -57,6 +72,7 @@ describe('PTY Tools', () => { title: undefined, notifyOnExit: undefined, timeoutSeconds: undefined, + nudgeIntervalSeconds: undefined, }) expect(result).toContain('') @@ -64,8 +80,14 @@ describe('PTY Tools', () => { expect(result).toContain('Command: echo hello') expect(result).toContain('NotifyOnExit: false') expect(result).toContain('TimeoutSeconds: none') + expect(result).toContain('Nudges: automatic step 1') expect(result).toContain('') - expect(result).not.toContain('') + expect(result).toContain( + 'Future `` messages mean this PTY is still running; they are not completion signals.' + ) + expect(result).toContain('Use your judgment to inspect it with `pty_read`') + expect(result).toContain('Avoid sleep-and-`pty_read` polling loops') + expect(result.endsWith(EXPECTED_NUDGE_REMINDER)).toBe(true) }) it('should spawn with all optional args', async () => { @@ -88,6 +110,7 @@ describe('PTY Tools', () => { description: 'Running Node.js script', notifyOnExit: true, timeoutSeconds: 60, + nudgeIntervalSeconds: 15, } const result = await ptySpawn.execute(args, ctx) @@ -103,6 +126,7 @@ describe('PTY Tools', () => { parentAgent: 'test-agent', notifyOnExit: true, timeoutSeconds: 60, + nudgeIntervalSeconds: 15, }) expect(result).toContain('Title: My Node Session') @@ -112,6 +136,7 @@ describe('PTY Tools', () => { expect(result).toContain('Status: running') expect(result).toContain('NotifyOnExit: true') expect(result).toContain('TimeoutSeconds: 60') + expect(result).toContain('Nudges: recurring every 15s') expect(result).toContain('') expect(result).toContain( 'Completion signal for this session is the future `` message.' @@ -123,6 +148,56 @@ describe('PTY Tools', () => { 'Never use sleep plus `pty_read` loops to check completion for this session.' ) expect(result).toContain('') + expect(result).toContain( + 'Future `` messages mean this PTY is still running; they are not completion signals.' + ) + }) + }) + + describe('ptyNudge', () => { + beforeEach(() => { + spyOn(manager, 'configureNudge').mockImplementation((id, action, seconds) => ({ + id, + title: 'Test Session', + command: 'sleep', + args: ['60'], + workdir: '/tmp', + status: 'running', + notifyOnExit: false, + timedOut: false, + nudgeEnabled: true, + nudgePolicy: action === 'every' ? 'recurring' : 'automatic', + nudgePaused: action === 'pause', + nudgeAutomaticStep: 0, + nudgeIntervalSeconds: action === 'every' ? seconds : undefined, + nudgeNextDueAt: + action === 'pause' + ? undefined + : new Date(Date.now() + (seconds ?? 60) * 1000).toISOString(), + pid: 12345, + createdAt: new Date().toISOString(), + lineCount: 0, + })) + }) + + it('should set a recurring cadence', async () => { + const result = await ptyNudge.execute( + { id: 'test-session-id', action: 'every', seconds: 900 }, + {} as never + ) + + expect(manager.configureNudge).toHaveBeenCalledWith('test-session-id', 'every', 900) + expect(result).toContain('') + expect(result).toContain('Nudges: recurring every 900s') + expect(result).toContain('') + }) + + it('should report an unknown session', async () => { + spyOn(manager, 'configureNudge').mockReturnValue(null) + + expect( + ptyNudge.execute({ id: 'missing', action: 'pause', seconds: undefined }, {} as never) + ).rejects.toThrow("PTY session 'missing' not found") }) }) @@ -399,5 +474,26 @@ describe('PTY Tools', () => { expect(buffer.read()).toEqual(['ine3', 'line4']) expect(buffer.length).toBe(2) }) + + it('should read raw deltas using a cumulative position', () => { + const buffer = new RingBuffer(10) + buffer.append('first') + const firstPosition = buffer.position + buffer.append('-second-part') + + expect(buffer.readRawFrom(firstPosition)).toEqual({ + data: 'econd-part', + endPosition: 17, + truncated: true, + }) + + const latestPosition = buffer.position + buffer.append('-new') + expect(buffer.readRawFrom(latestPosition)).toEqual({ + data: '-new', + endPosition: 21, + truncated: false, + }) + }) }) }) diff --git a/test/stop-nudges-command.test.ts b/test/stop-nudges-command.test.ts new file mode 100644 index 00000000..6bca2656 --- /dev/null +++ b/test/stop-nudges-command.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' +import { PTYPlugin } from '../src/plugin.ts' +import { manager } from '../src/plugin/pty/manager.ts' +import type { PluginContext } from '../src/plugin/types.ts' + +type CommandHook = (input: { + command: string + sessionID: string + arguments: string +}) => Promise + +function createClient(statuses: Record = {}) { + const prompt = mock(async (_input: unknown) => ({})) + const showToast = mock(async (_input: unknown) => ({})) + const status = mock(async (_input: unknown) => ({ data: statuses })) + return { + client: { + session: { prompt, status }, + tui: { showToast }, + } as unknown as PluginContext['client'], + prompt, + showToast, + } +} + +describe('/stopnudges command', () => { + afterEach(() => { + mock.restore() + }) + + it('registers the command', async () => { + const { client } = createClient() + const plugin = await PTYPlugin({ client, directory: '/tmp' } as PluginContext) + const config = { command: {} } as { command: Record } + + await plugin.config?.(config as never) + + expect(config.command.stopnudges).toEqual({ + template: 'Pause all PTY nudges for this chat without stopping the processes.', + description: 'Stop PTY nudges for this chat', + }) + }) + + it('pauses nudges when the parent chat is idle', async () => { + const { client, prompt, showToast } = createClient() + const pause = spyOn(manager, 'pauseNudgesByParentSession').mockReturnValue([ + 'pty_a1b2c3d4', + 'pty_e5f6g7h8', + ]) + const plugin = await PTYPlugin({ client, directory: '/tmp' } as PluginContext) + const hook = plugin['command.execute.before'] as CommandHook + + await expect( + hook({ command: 'stopnudges', sessionID: 'parent-session', arguments: '' }) + ).rejects.toThrow('Command handled by PTY plugin') + + expect(pause).toHaveBeenCalledWith('parent-session') + expect(prompt).toHaveBeenCalledWith({ + path: { id: 'parent-session' }, + body: { + noReply: true, + parts: [ + { + type: 'text', + text: [ + '', + 'ID: pty_a1b2c3d4, pty_e5f6g7h8', + 'Nudge Status: paused by user', + 'Resume with: pty_nudge(id="...", action="resume")', + '', + ].join('\n'), + }, + ], + }, + }) + expect(showToast).toHaveBeenCalledWith({ + body: { + title: 'PTY Nudges', + message: 'Paused nudges for 2 PTY processes.', + variant: 'success', + duration: 3000, + }, + }) + }) + + it('does not pause nudges while the parent chat is busy', async () => { + const { client, prompt, showToast } = createClient({ 'parent-session': { type: 'busy' } }) + const pause = spyOn(manager, 'pauseNudgesByParentSession').mockReturnValue(['pty_a1b2c3d4']) + const plugin = await PTYPlugin({ client, directory: '/tmp' } as PluginContext) + const hook = plugin['command.execute.before'] as CommandHook + + await expect( + hook({ command: 'stopnudges', sessionID: 'parent-session', arguments: '' }) + ).rejects.toThrow('Command handled by PTY plugin') + + expect(pause).not.toHaveBeenCalled() + expect(prompt).not.toHaveBeenCalled() + expect(showToast).toHaveBeenCalledWith({ + body: { + title: 'PTY Nudges', + message: 'Cannot stop PTY nudges while this chat is busy.', + variant: 'error', + duration: 3000, + }, + }) + }) +}) diff --git a/test/web-server.test.ts b/test/web-server.test.ts index 991bed31..f8167846 100644 --- a/test/web-server.test.ts +++ b/test/web-server.test.ts @@ -102,6 +102,23 @@ describe('Web Server', () => { expect(Array.isArray(sessions)).toBe(true) }) + it('should disable agent nudges for sessions created through the web API', async () => { + const response = await fetch(`${managedTestServer.server.server.url}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + command: 'sleep', + args: ['60'], + description: 'Web API nudge isolation', + }), + }) + + expect(response.status).toBe(200) + const session = (await response.json()) as PTYSessionInfo + expect(session.nudgeEnabled).toBe(false) + manager.kill(session.id, true) + }) + it('should return individual session', async () => { // Create a test session first const session = manager.spawn({