diff --git a/package-lock.json b/package-lock.json index ecbeca63..a9b57e32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1865,6 +1865,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2484,6 +2485,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -3733,7 +3735,8 @@ "version": "0.0.1521046", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/dir-glob": { "version": "3.0.1", @@ -10097,6 +10100,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10433,6 +10437,7 @@ "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10811,6 +10816,7 @@ "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -10858,6 +10864,7 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -10978,6 +10985,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11463,6 +11471,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/server.ts b/src/server.ts index 0d27fef9..a3539211 100644 --- a/src/server.ts +++ b/src/server.ts @@ -780,35 +780,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "read_process_output", description: ` - Read output from a running process with intelligent completion detection. + Read output from a running process with file-like pagination support. - Automatically detects when process is ready for more input instead of timing out. + Supports partial output reading with offset and length parameters (like read_file): + - 'offset' (start line, default: 0) + * offset=0: Read NEW output since last read (default, like old behavior) + * Positive: Read from absolute line position + * Negative: Read last N lines from end (tail behavior) + - 'length' (max lines to read, default: configurable via 'fileReadLineLimit' setting) - SMART FEATURES: - - Early exit when REPL shows prompt (>>>, >, etc.) - - Detects process completion vs still running - - Prevents hanging on interactive prompts - - Clear status messages about process state + Examples: + - offset: 0, length: 100 → First 100 NEW lines since last read + - offset: 0 → All new lines (respects config limit) + - offset: 500, length: 50 → Lines 500-549 (absolute position) + - offset: -20 → Last 20 lines (tail) + - offset: -50, length: 10 → Start 50 from end, read 10 lines + + OUTPUT PROTECTION: + - Uses same fileReadLineLimit as read_file (default: 1000 lines) + - Returns status like: [Reading 100 lines from line 0 (total: 5000 lines, 4900 remaining)] + - Prevents context overflow from verbose processes - REPL USAGE: - - Stops immediately when REPL prompt detected - - Shows clear status: waiting for input vs finished - - Shorter timeouts needed due to smart detection - - Works with Python, Node.js, R, Julia, etc. + SMART FEATURES: + - For offset=0, waits up to timeout_ms for new output to arrive + - Detects REPL prompts and process completion + - Shows process state (waiting for input, finished, etc.) DETECTION STATES: Process waiting for input (ready for interact_with_process) Process finished execution Timeout reached (may still be running) - PERFORMANCE DEBUGGING (verbose_timing parameter): - Set verbose_timing: true to get detailed timing information including: - - Exit reason (early_exit_quick_pattern, early_exit_periodic_check, process_finished, timeout) - - Total duration and time to first output - - Complete timeline of all output events with timestamps - - Which detection mechanism triggered early exit - Use this to identify when timeouts could be reduced or detection patterns improved. - ${CMD_PREFIX_DESCRIPTION}`, inputSchema: zodToJsonSchema(ReadProcessOutputArgsSchema), annotations: { diff --git a/src/terminal-manager.ts b/src/terminal-manager.ts index c6cb7d74..0c902c43 100644 --- a/src/terminal-manager.ts +++ b/src/terminal-manager.ts @@ -8,12 +8,24 @@ import { analyzeProcessState } from './utils/process-detection.js'; interface CompletedSession { pid: number; - output: string; + outputLines: string[]; // Line-based buffer (consistent with active sessions) exitCode: number | null; startTime: Date; endTime: Date; } +// Result type for paginated output reading +export interface PaginatedOutputResult { + lines: string[]; + totalLines: number; + readFrom: number; // Starting line of this read + readCount: number; // Number of lines returned + remaining: number; // Lines remaining after this read + isComplete: boolean; // Whether process has finished + exitCode?: number | null; // Exit code if completed + runtimeMs?: number; // Runtime in milliseconds (for completed processes) +} + /** * Configuration for spawning a shell with appropriate flags */ @@ -188,7 +200,8 @@ export class TerminalManager { const session: TerminalSession = { pid: childProcess.pid, process: childProcess, - lastOutput: '', + outputLines: [], // Line-based buffer + lastReadIndex: 0, // Track where "new" output starts isBlocked: false, startTime: new Date() }; @@ -240,7 +253,8 @@ export class TerminalManager { lastOutputTime = now; output += text; - session.lastOutput += text; + // Append to line-based buffer + this.appendToLineBuffer(session, text); // Record output event if collecting timing if (collectTiming) { @@ -278,7 +292,8 @@ export class TerminalManager { lastOutputTime = now; output += text; - session.lastOutput += text; + // Append to line-based buffer + this.appendToLineBuffer(session, text); // Record output event if collecting timing if (collectTiming) { @@ -324,7 +339,7 @@ export class TerminalManager { // Store completed session before removing active session this.completedSessions.set(childProcess.pid, { pid: childProcess.pid, - output: output, // Use only the main output variable + outputLines: [...session.outputLines], // Copy line buffer exitCode: code, startTime: session.startTime, endTime: new Date() @@ -348,29 +363,219 @@ export class TerminalManager { }); } - getNewOutput(pid: number): string | null { + /** + * Append text to a session's line buffer + * Handles partial lines and newline splitting + */ + private appendToLineBuffer(session: TerminalSession, text: string): void { + if (!text) return; + + // Split text into lines, keeping track of whether text ends with newline + const lines = text.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const isLastFragment = i === lines.length - 1; + const endsWithNewline = text.endsWith('\n'); + + if (session.outputLines.length === 0) { + // First line ever + session.outputLines.push(line); + } else if (i === 0) { + // First fragment - append to last line (might be partial) + session.outputLines[session.outputLines.length - 1] += line; + } else { + // Subsequent lines - add as new lines + session.outputLines.push(line); + } + } + } + + /** + * Read process output with pagination (like file reading) + * @param pid Process ID + * @param offset Line offset: 0=from lastReadIndex, positive=absolute, negative=tail + * @param length Max lines to return + * @param updateReadIndex Whether to update lastReadIndex (default: true for offset=0) + */ + readOutputPaginated(pid: number, offset: number = 0, length: number = 1000): PaginatedOutputResult | null { // First check active sessions const session = this.sessions.get(pid); if (session) { - const output = session.lastOutput; - session.lastOutput = ''; - return output; + return this.readFromLineBuffer( + session.outputLines, + offset, + length, + session.lastReadIndex, + (newIndex) => { session.lastReadIndex = newIndex; }, + false, + undefined + ); } // Then check completed sessions const completedSession = this.completedSessions.get(pid); if (completedSession) { - // Format with output first, then completion info - const runtime = (completedSession.endTime.getTime() - completedSession.startTime.getTime()) / 1000; - const output = completedSession.output.trim(); - + const runtimeMs = completedSession.endTime.getTime() - completedSession.startTime.getTime(); + return this.readFromLineBuffer( + completedSession.outputLines, + offset, + length, + 0, // Completed sessions don't track read position + () => {}, // No-op for completed sessions + true, + completedSession.exitCode, + runtimeMs + ); + } + + return null; + } + + /** + * Internal helper to read from a line buffer with offset/length + */ + private readFromLineBuffer( + lines: string[], + offset: number, + length: number, + lastReadIndex: number, + updateLastRead: (index: number) => void, + isComplete: boolean, + exitCode?: number | null, + runtimeMs?: number + ): PaginatedOutputResult { + const totalLines = lines.length; + let startIndex: number; + let linesToRead: string[]; + + if (offset < 0) { + // Negative offset = start position from end, then read 'length' lines forward + // e.g., offset=-50, length=10 means: start 50 lines from end, read 10 lines + const fromEnd = Math.abs(offset); + startIndex = Math.max(0, totalLines - fromEnd); + linesToRead = lines.slice(startIndex, startIndex + length); + // Don't update lastReadIndex for tail reads + } else if (offset === 0) { + // offset=0 means "from where I last read" (like getNewOutput) + startIndex = lastReadIndex; + linesToRead = lines.slice(startIndex, startIndex + length); + // Update lastReadIndex for "new output" behavior + updateLastRead(Math.min(startIndex + linesToRead.length, totalLines)); + } else { + // Positive offset = absolute position + startIndex = offset; + linesToRead = lines.slice(startIndex, startIndex + length); + // Don't update lastReadIndex for absolute position reads + } + + const readCount = linesToRead.length; + const endIndex = startIndex + readCount; + const remaining = Math.max(0, totalLines - endIndex); + + return { + lines: linesToRead, + totalLines, + readFrom: startIndex, + readCount, + remaining, + isComplete, + exitCode, + runtimeMs + }; + } + + /** + * Get total line count for a process + */ + getOutputLineCount(pid: number): number | null { + const session = this.sessions.get(pid); + if (session) { + return session.outputLines.length; + } + + const completedSession = this.completedSessions.get(pid); + if (completedSession) { + return completedSession.outputLines.length; + } + + return null; + } + + /** + * Legacy method for backward compatibility + * Returns all new output since last read + * @param maxLines Maximum lines to return (default: 1000 for context protection) + * @deprecated Use readOutputPaginated instead + */ + getNewOutput(pid: number, maxLines: number = 1000): string | null { + const result = this.readOutputPaginated(pid, 0, maxLines); + if (!result) return null; + + const output = result.lines.join('\n').trim(); + + // For completed sessions, append completion info with runtime + if (result.isComplete) { + const runtimeStr = result.runtimeMs !== undefined + ? `\nRuntime: ${(result.runtimeMs / 1000).toFixed(2)}s` + : ''; if (output) { - return `${output}\n\nProcess completed with exit code ${completedSession.exitCode}\nRuntime: ${runtime}s`; + return `${output}\n\nProcess completed with exit code ${result.exitCode}${runtimeStr}`; } else { - return `Process completed with exit code ${completedSession.exitCode}\nRuntime: ${runtime}s\n(No output produced)`; + return `Process completed with exit code ${result.exitCode}${runtimeStr}\n(No output produced)`; } } + // Add truncation warning if there's more output + if (result.remaining > 0) { + return `${output}\n\n[Output truncated: ${result.remaining} more lines available. Use read_process_output with offset/length for full output.]`; + } + + return output || null; + } + + /** + * Capture a snapshot of current output state for interaction tracking. + * Used by interactWithProcess to know what output existed before sending input. + */ + captureOutputSnapshot(pid: number): { totalChars: number; lineCount: number } | null { + const session = this.sessions.get(pid); + if (session) { + const fullOutput = session.outputLines.join('\n'); + return { + totalChars: fullOutput.length, + lineCount: session.outputLines.length + }; + } + return null; + } + + /** + * Get output that appeared since a snapshot was taken. + * This handles the case where output is appended to the last line (REPL prompts). + * Also checks completed sessions in case process finished between snapshot and poll. + */ + getOutputSinceSnapshot(pid: number, snapshot: { totalChars: number; lineCount: number }): string | null { + // Check active session first + const session = this.sessions.get(pid); + if (session) { + const fullOutput = session.outputLines.join('\n'); + if (fullOutput.length <= snapshot.totalChars) { + return ''; // No new output + } + return fullOutput.substring(snapshot.totalChars); + } + + // Fallback to completed sessions - process may have finished between snapshot and poll + const completedSession = this.completedSessions.get(pid); + if (completedSession) { + const fullOutput = completedSession.outputLines.join('\n'); + if (fullOutput.length <= snapshot.totalChars) { + return ''; // No new output + } + return fullOutput.substring(snapshot.totalChars); + } + return null; } diff --git a/src/tools/improved-process-tools.ts b/src/tools/improved-process-tools.ts index ace21706..8c7a42e1 100644 --- a/src/tools/improved-process-tools.ts +++ b/src/tools/improved-process-tools.ts @@ -235,8 +235,8 @@ function formatTimingInfo(timing: any): string { } /** - * Read output from a running process (renamed from read_output) - * Includes early detection of process waiting for input + * Read output from a running process with file-like pagination + * Supports offset/length parameters for controlled reading */ export async function readProcessOutput(args: unknown): Promise { const parsed = ReadProcessOutputArgsSchema.safeParse(args); @@ -247,257 +247,128 @@ export async function readProcessOutput(args: unknown): Promise { }; } - const { pid, timeout_ms = 5000, verbose_timing = false } = parsed.data; - - const session = terminalManager.getSession(pid); - if (!session) { - // Check if this is a completed session - const completedOutput = terminalManager.getNewOutput(pid); - if (completedOutput) { - return { - content: [{ - type: "text", - text: completedOutput - }], - }; - } + // Get default line limit from config + const config = await configManager.getConfig(); + const defaultLength = config.fileReadLineLimit ?? 1000; - // Neither active nor completed session found - return { - content: [{ type: "text", text: `No session found for PID ${pid}` }], - isError: true, - }; - } - - let output = ""; - let timeoutReached = false; - let earlyExit = false; - let processState: ProcessState | undefined; + const { + pid, + timeout_ms = 5000, + offset = 0, // 0 = from last read, positive = absolute, negative = tail + length = defaultLength, // Default from config, same as file reading + verbose_timing = false + } = parsed.data; // Timing telemetry const startTime = Date.now(); - let firstOutputTime: number | undefined; - let lastOutputTime: number | undefined; - const outputEvents: any[] = []; - let exitReason: 'early_exit_quick_pattern' | 'early_exit_periodic_check' | 'process_finished' | 'timeout' = 'timeout'; - try { - const outputPromise: Promise = new Promise((resolve) => { - const initialOutput = terminalManager.getNewOutput(pid); - if (initialOutput && initialOutput.length > 0) { - const now = Date.now(); - if (!firstOutputTime) firstOutputTime = now; - lastOutputTime = now; - - if (verbose_timing) { - outputEvents.push({ - timestamp: now, - deltaMs: now - startTime, - source: 'initial_poll', - length: initialOutput.length, - snippet: initialOutput.slice(0, 50).replace(/\n/g, '\\n') - }); - } - - // Immediate check on existing output - const state = analyzeProcessState(initialOutput, pid); - if (state.isWaitingForInput) { - earlyExit = true; - processState = state; - exitReason = 'early_exit_periodic_check'; + // For active sessions with no new output yet, optionally wait for output + const session = terminalManager.getSession(pid); + if (session && offset === 0) { + // Wait for new output to arrive (only for "new output" reads, not absolute/tail) + const waitForOutput = (): Promise => { + return new Promise((resolve) => { + // Check if there's already new output + const currentLines = terminalManager.getOutputLineCount(pid) || 0; + if (currentLines > session.lastReadIndex) { + resolve(); + return; } - resolve(initialOutput); - return; - } - - let resolved = false; - let interval: NodeJS.Timeout | null = null; - let timeout: NodeJS.Timeout | null = null; - - // Quick prompt patterns for immediate detection - const quickPromptPatterns = />>>\s*$|>\s*$|\$\s*$|#\s*$/; - - const cleanup = () => { - if (interval) clearInterval(interval); - if (timeout) clearTimeout(timeout); - }; - - let resolveOnce = (value: string, isTimeout = false) => { - if (resolved) return; - resolved = true; - cleanup(); - timeoutReached = isTimeout; - if (isTimeout) exitReason = 'timeout'; - resolve(value); - }; - // Monitor for new output with immediate detection - const session = terminalManager.getSession(pid); - if (session && session.process && session.process.stdout && session.process.stderr) { - const immediateDetector = (data: Buffer, source: 'stdout' | 'stderr') => { - const text = data.toString(); - const now = Date.now(); - - if (!firstOutputTime) firstOutputTime = now; - lastOutputTime = now; - - if (verbose_timing) { - outputEvents.push({ - timestamp: now, - deltaMs: now - startTime, - source, - length: text.length, - snippet: text.slice(0, 50).replace(/\n/g, '\\n') - }); - } - - // Immediate check for obvious prompts - if (quickPromptPatterns.test(text)) { - const newOutput = terminalManager.getNewOutput(pid) || text; - const state = analyzeProcessState(output + newOutput, pid); - if (state.isWaitingForInput) { - earlyExit = true; - processState = state; - exitReason = 'early_exit_quick_pattern'; - - if (verbose_timing && outputEvents.length > 0) { - outputEvents[outputEvents.length - 1].matchedPattern = 'quick_pattern'; - } - - resolveOnce(newOutput); - return; - } - } - }; - - const stdoutDetector = (data: Buffer) => immediateDetector(data, 'stdout'); - const stderrDetector = (data: Buffer) => immediateDetector(data, 'stderr'); - session.process.stdout.on('data', stdoutDetector); - session.process.stderr.on('data', stderrDetector); + let resolved = false; + let interval: NodeJS.Timeout | null = null; + let timeout: NodeJS.Timeout | null = null; - // Cleanup immediate detectors when done - const originalResolveOnce = resolveOnce; - const cleanupDetectors = () => { - if (session.process.stdout) { - session.process.stdout.off('data', stdoutDetector); - } - if (session.process.stderr) { - session.process.stderr.off('data', stderrDetector); - } + const cleanup = () => { + if (interval) clearInterval(interval); + if (timeout) clearTimeout(timeout); }; - // Override resolveOnce to include cleanup - const resolveOnceWithCleanup = (value: string, isTimeout = false) => { - cleanupDetectors(); - originalResolveOnce(value, isTimeout); + const resolveOnce = () => { + if (resolved) return; + resolved = true; + cleanup(); + resolve(); }; - // Replace the local resolveOnce reference - resolveOnce = resolveOnceWithCleanup; - } - - interval = setInterval(() => { - const newOutput = terminalManager.getNewOutput(pid); - if (newOutput && newOutput.length > 0) { - const now = Date.now(); - if (!firstOutputTime) firstOutputTime = now; - lastOutputTime = now; - - if (verbose_timing) { - outputEvents.push({ - timestamp: now, - deltaMs: now - startTime, - source: 'periodic_poll', - length: newOutput.length, - snippet: newOutput.slice(0, 50).replace(/\n/g, '\\n') - }); - } - - const currentOutput = output + newOutput; - const state = analyzeProcessState(currentOutput, pid); - - // Early exit if process is clearly waiting for input - if (state.isWaitingForInput) { - earlyExit = true; - processState = state; - exitReason = 'early_exit_periodic_check'; - - if (verbose_timing && outputEvents.length > 0) { - outputEvents[outputEvents.length - 1].matchedPattern = 'periodic_check'; - } - - resolveOnce(newOutput); - return; - } - - output = currentOutput; - - // Continue collecting if still running - if (!state.isFinished) { - return; + // Poll for new output + interval = setInterval(() => { + const newLineCount = terminalManager.getOutputLineCount(pid) || 0; + if (newLineCount > session.lastReadIndex) { + resolveOnce(); } + }, 50); - // Process finished - processState = state; - exitReason = 'process_finished'; - resolveOnce(newOutput); - } - }, 50); // Check every 50ms for faster response - - timeout = setTimeout(() => { - const finalOutput = terminalManager.getNewOutput(pid) || ""; - resolveOnce(finalOutput, true); - }, timeout_ms); - }); + // Timeout + timeout = setTimeout(() => { + resolveOnce(); + }, timeout_ms); + }); + }; - const newOutput = await outputPromise; - output += newOutput; - - // Analyze final state if not already done - if (!processState) { - processState = analyzeProcessState(output, pid); - } + await waitForOutput(); + } - } catch (error) { + // Read output with pagination + const result = terminalManager.readOutputPaginated(pid, offset, length); + + if (!result) { return { - content: [{ type: "text", text: `Error reading output: ${error}` }], + content: [{ type: "text", text: `No session found for PID ${pid}` }], isError: true, }; } - // Format response based on what we detected + // Join lines back into string + const output = result.lines.join('\n'); + + // Generate status message similar to file reading let statusMessage = ''; - if (earlyExit && processState?.isWaitingForInput) { - statusMessage = `\n🔄 ${formatProcessStateMessage(processState, pid)}`; - } else if (processState?.isFinished) { - statusMessage = `\n✅ ${formatProcessStateMessage(processState, pid)}`; - } else if (timeoutReached) { - statusMessage = '\n⏱️ Timeout reached - process may still be running'; + if (offset < 0) { + // Tail read - show actual line range + const endLine = result.readFrom + result.readCount - 1; + statusMessage = `[Reading lines ${result.readFrom}-${endLine} (${result.readCount} lines, starting ${Math.abs(offset)} from end, total: ${result.totalLines} lines)]`; + } else if (offset === 0) { + // "New output" read + if (result.remaining > 0) { + statusMessage = `[Reading ${result.readCount} new lines from line ${result.readFrom} (total: ${result.totalLines} lines, ${result.remaining} remaining)]`; + } else { + statusMessage = `[Reading ${result.readCount} new lines (total: ${result.totalLines} lines)]`; + } + } else { + // Absolute position read + statusMessage = `[Reading ${result.readCount} lines from line ${result.readFrom} (total: ${result.totalLines} lines, ${result.remaining} remaining)]`; + } + + // Add process state info + let processStateMessage = ''; + if (result.isComplete) { + const runtimeStr = result.runtimeMs !== undefined + ? ` (runtime: ${(result.runtimeMs / 1000).toFixed(2)}s)` + : ''; + processStateMessage = `\n✅ Process completed with exit code ${result.exitCode}${runtimeStr}`; + } else if (session) { + // Analyze state for running processes + const fullOutput = session.outputLines.join('\n'); + const processState = analyzeProcessState(fullOutput, pid); + if (processState.isWaitingForInput) { + processStateMessage = `\n🔄 ${formatProcessStateMessage(processState, pid)}`; + } } // Add timing information if requested let timingMessage = ''; if (verbose_timing) { const endTime = Date.now(); - const timingInfo = { - startTime, - endTime, - totalDurationMs: endTime - startTime, - exitReason, - firstOutputTime, - lastOutputTime, - timeToFirstOutputMs: firstOutputTime ? firstOutputTime - startTime : undefined, - outputEvents: outputEvents.length > 0 ? outputEvents : undefined - }; - timingMessage = formatTimingInfo(timingInfo); + timingMessage = `\n\n📊 Timing: ${endTime - startTime}ms`; } - const responseText = output || 'No new output available'; + const responseText = output || '(No output in requested range)'; return { content: [{ type: "text", - text: `${responseText}${statusMessage}${timingMessage}` + text: `${statusMessage}\n\n${responseText}${processStateMessage}${timingMessage}` }], }; } @@ -526,6 +397,10 @@ export async function interactWithProcess(args: unknown): Promise verbose_timing = false } = parsed.data; + // Get config for output line limit + const config = await configManager.getConfig(); + const maxOutputLines = config.fileReadLineLimit ?? 1000; + // Check if this is a virtual Node session (node:local) if (virtualNodeSessions.has(pid)) { const session = virtualNodeSessions.get(pid)!; @@ -553,6 +428,10 @@ export async function interactWithProcess(args: unknown): Promise inputLength: input.length }); + // Capture output snapshot BEFORE sending input + // This handles REPLs where output is appended to the prompt line + const outputSnapshot = terminalManager.captureOutputSnapshot(pid); + const success = terminalManager.sendInputToProcess(pid, input); if (!success) { @@ -603,6 +482,7 @@ export async function interactWithProcess(args: unknown): Promise const pollIntervalMs = 50; // Poll every 50ms for faster response const maxAttempts = Math.ceil(timeout_ms / pollIntervalMs); let interval: NodeJS.Timeout | null = null; + let lastOutputLength = 0; // Track output length to detect new output let resolveOnce = () => { if (resolved) return; @@ -615,8 +495,12 @@ export async function interactWithProcess(args: unknown): Promise interval = setInterval(() => { if (resolved) return; - const newOutput = terminalManager.getNewOutput(pid); - if (newOutput && newOutput.length > 0) { + // Use snapshot-based reading to handle REPL prompt line appending + const newOutput = outputSnapshot + ? terminalManager.getOutputSinceSnapshot(pid, outputSnapshot) + : terminalManager.getNewOutput(pid); + + if (newOutput && newOutput.length > lastOutputLength) { const now = Date.now(); if (!firstOutputTime) firstOutputTime = now; lastOutputTime = now; @@ -626,12 +510,13 @@ export async function interactWithProcess(args: unknown): Promise timestamp: now, deltaMs: now - startTime, source: 'periodic_poll', - length: newOutput.length, - snippet: newOutput.slice(0, 50).replace(/\n/g, '\\n') + length: newOutput.length - lastOutputLength, + snippet: newOutput.slice(lastOutputLength, lastOutputLength + 50).replace(/\n/g, '\\n') }); } - output += newOutput; + output = newOutput; // Replace with full output since snapshot + lastOutputLength = newOutput.length; // Analyze current state processState = analyzeProcessState(output, pid); @@ -669,9 +554,19 @@ export async function interactWithProcess(args: unknown): Promise await waitForResponse(); // Clean and format output - const cleanOutput = cleanProcessOutput(output, input); + let cleanOutput = cleanProcessOutput(output, input); const timeoutReached = !earlyExit && !processState?.isFinished && !processState?.isWaitingForInput; + // Apply output line limit to prevent context overflow + let truncationMessage = ''; + const outputLines = cleanOutput.split('\n'); + if (outputLines.length > maxOutputLines) { + const truncatedLines = outputLines.slice(0, maxOutputLines); + cleanOutput = truncatedLines.join('\n'); + const remainingLines = outputLines.length - maxOutputLines; + truncationMessage = `\n\n⚠️ Output truncated: showing ${maxOutputLines} of ${outputLines.length} lines (${remainingLines} hidden). Use read_process_output with offset/length for full output.`; + } + // Determine final state if (!processState) { processState = analyzeProcessState(output, pid); @@ -725,6 +620,10 @@ export async function interactWithProcess(args: unknown): Promise responseText += `\n\n${statusMessage}`; } + if (truncationMessage) { + responseText += truncationMessage; + } + if (timingMessage) { responseText += timingMessage; } diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index 0406ba86..906a9fec 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -28,6 +28,8 @@ export const StartProcessArgsSchema = z.object({ export const ReadProcessOutputArgsSchema = z.object({ pid: z.number(), timeout_ms: z.number().optional(), + offset: z.number().optional(), // Line offset: 0=from last read, positive=absolute, negative=tail + length: z.number().optional(), // Max lines to return (default from config.fileReadLineLimit) verbose_timing: z.boolean().optional(), }); diff --git a/src/types.ts b/src/types.ts index fb2f7d94..1d030f86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,8 @@ export interface ProcessInfo { export interface TerminalSession { pid: number; process: ChildProcess; - lastOutput: string; + outputLines: string[]; // Line-based buffer (persistent) + lastReadIndex: number; // Track where "new" output starts for default reads isBlocked: boolean; startTime: Date; } diff --git a/test/test-process-pagination.js b/test/test-process-pagination.js new file mode 100644 index 00000000..30da2c02 --- /dev/null +++ b/test/test-process-pagination.js @@ -0,0 +1,268 @@ +import assert from 'assert'; +import { startProcess, readProcessOutput, interactWithProcess } from '../dist/tools/improved-process-tools.js'; + +/** + * Test suite for process output pagination features + * Tests offset/length parameters and context overflow protection + */ + +// Helper to extract PID from start result +function extractPid(result) { + const match = result.content[0].text.match(/PID (\d+)/); + return match ? parseInt(match[1]) : null; +} + +// Helper to wait +const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +/** + * Test 1: Basic offset=0 (new output) behavior for RUNNING processes + */ +async function testNewOutputBehavior() { + console.log('\n📋 Test 1: Basic new output behavior (offset=0) for running process...'); + + // Start a long-running process that outputs incrementally + const startResult = await startProcess({ + command: 'node -e "let i=0; setInterval(() => { console.log(\'tick\' + i++); if(i>5) process.exit(0); }, 200)"', + timeout_ms: 500 // Return before completion + }); + + const pid = extractPid(startResult); + assert(pid, 'Should get PID'); + + // First read - get initial output + const read1 = await readProcessOutput({ pid, timeout_ms: 300 }); + assert(!read1.isError, 'First read should succeed'); + const lines1 = (read1.content[0].text.match(/tick\d/g) || []).length; + console.log(` First read got ${lines1} tick lines`); + + // Wait for more output + await wait(400); + + // Second read should get NEW output only + const read2 = await readProcessOutput({ pid, timeout_ms: 300 }); + assert(!read2.isError, 'Second read should succeed'); + const text2 = read2.content[0].text; + + // Should NOT re-read tick0 if we already read it + // (unless process completed, in which case all output is available) + if (text2.includes('Process completed')) { + console.log(' Process completed - all output available'); + } else { + console.log(` Second read status: ${text2.split('\n')[0]}`); + } + + console.log('✅ Test 1 passed: New output behavior works correctly'); +} + +/** + * Test 2: Positive offset (absolute position) + */ +async function testAbsoluteOffset() { + console.log('\n📋 Test 2: Absolute position (positive offset)...'); + + const startResult = await startProcess({ + command: "node -e \"for(let i=0; i<10; i++) console.log('line' + i)\"", + timeout_ms: 3000 + }); + + const pid = extractPid(startResult); + assert(pid, 'Should get PID'); + + await wait(500); + + // Read from line 5 + const read = await readProcessOutput({ pid, offset: 5, length: 3, timeout_ms: 1000 }); + assert(!read.isError, 'Read should succeed'); + assert(read.content[0].text.includes('line5'), 'Should contain line5'); + assert(read.content[0].text.includes('line6'), 'Should contain line6'); + assert(read.content[0].text.includes('line7'), 'Should contain line7'); + assert(!read.content[0].text.includes('line4'), 'Should NOT contain line4'); + assert(read.content[0].text.includes('from line 5'), 'Status should show reading from line 5'); + + console.log('✅ Test 2 passed: Absolute position works correctly'); +} + +/** + * Test 3: Negative offset (tail behavior) + */ +async function testTailBehavior() { + console.log('\n📋 Test 3: Tail behavior (negative offset)...'); + + const startResult = await startProcess({ + command: "node -e \"for(let i=0; i<20; i++) console.log('line' + i)\"", + timeout_ms: 3000 + }); + + const pid = extractPid(startResult); + assert(pid, 'Should get PID'); + + await wait(500); + + // Read last 5 lines (output has 21 lines: line0-line19 + empty) + // Last 5 lines should include line16, line17, line18, line19 + const read = await readProcessOutput({ pid, offset: -5, timeout_ms: 1000 }); + assert(!read.isError, 'Read should succeed'); + assert(read.content[0].text.includes('line16'), 'Should contain line16'); + assert(read.content[0].text.includes('line19'), 'Should contain line19'); + assert(!read.content[0].text.includes('line15'), 'Should NOT contain line15'); + assert(read.content[0].text.includes('Reading last'), 'Status should indicate tail read'); + + console.log('✅ Test 3 passed: Tail behavior works correctly'); +} + +/** + * Test 4: Length limit enforcement + */ +async function testLengthLimit() { + console.log('\n📋 Test 4: Length limit enforcement...'); + + const startResult = await startProcess({ + command: "node -e \"for(let i=0; i<100; i++) console.log('line' + i)\"", + timeout_ms: 3000 + }); + + const pid = extractPid(startResult); + assert(pid, 'Should get PID'); + + await wait(500); + + // Read with length limit of 10 from absolute position 0 + const read = await readProcessOutput({ pid, offset: 1, length: 10, timeout_ms: 1000 }); + assert(!read.isError, 'Read should succeed'); + + const outputText = read.content[0].text; + + // Should show "remaining" since we're only reading 10 of 100 lines + assert(outputText.includes('remaining'), 'Should show remaining lines'); + assert(outputText.includes('Reading 10 lines'), 'Should indicate reading 10 lines'); + + console.log('✅ Test 4 passed: Length limit works correctly'); +} + +/** + * Test 5: Runtime info for completed processes + */ +async function testRuntimeInfo() { + console.log('\n📋 Test 5: Runtime info for completed processes...'); + + const startResult = await startProcess({ + command: "node -e \"setTimeout(() => console.log('done'), 500)\"", + timeout_ms: 200 // Return before completion + }); + + const pid = extractPid(startResult); + assert(pid, 'Should get PID'); + + // Wait for process to complete + await wait(1000); + + const read = await readProcessOutput({ pid, timeout_ms: 1000 }); + assert(!read.isError, 'Read should succeed'); + assert(read.content[0].text.includes('runtime:'), 'Should show runtime'); + assert(read.content[0].text.includes('Process completed'), 'Should show completion'); + + console.log('✅ Test 5 passed: Runtime info works correctly'); +} + +/** + * Test 6: interact_with_process output truncation + */ +async function testInteractTruncation() { + console.log('\n📋 Test 6: interact_with_process output truncation...'); + + // Start a Python REPL + const startResult = await startProcess({ + command: 'python3 -i', + timeout_ms: 3000 + }); + + const pid = extractPid(startResult); + if (!pid) { + console.log('⚠️ Test 6 skipped: Could not start Python REPL'); + return; + } + + await wait(500); + + // Generate lots of output (more than default 1000 lines) + const result = await interactWithProcess({ + pid, + input: 'for i in range(1500): print(f"line {i}")', + timeout_ms: 10000 + }); + + if (result.isError) { + console.log('⚠️ Test 6 skipped: Python interaction failed'); + return; + } + + const outputText = result.content[0].text; + + // Check for truncation warning + if (outputText.includes('truncated')) { + assert(outputText.includes('use read_process_output'), 'Should suggest using read_process_output'); + console.log('✅ Test 6 passed: Output truncation warning works'); + } else { + // If fileReadLineLimit is set higher than 1500, no truncation expected + console.log('✅ Test 6 passed: Output within limits (no truncation needed)'); + } +} + +/** + * Test 7: Re-reading output with absolute offset + */ +async function testReReadOutput() { + console.log('\n📋 Test 7: Re-reading output with absolute offset...'); + + const startResult = await startProcess({ + command: "node -e \"for(let i=0; i<5; i++) console.log('data' + i)\"", + timeout_ms: 3000 + }); + + const pid = extractPid(startResult); + assert(pid, 'Should get PID'); + + await wait(500); + + // First read with offset=0 (consumes the "new" pointer for running sessions) + const read1 = await readProcessOutput({ pid, offset: 0, timeout_ms: 1000 }); + assert(!read1.isError, 'First read should succeed'); + + // Re-read from beginning using absolute offset + const read2 = await readProcessOutput({ pid, offset: 1, length: 3, timeout_ms: 1000 }); + assert(!read2.isError, 'Second read should succeed'); + assert(read2.content[0].text.includes('data1'), 'Should re-read data1'); + assert(read2.content[0].text.includes('data2'), 'Should re-read data2'); + + console.log('✅ Test 7 passed: Re-reading with absolute offset works'); +} + +// Run all tests +async function runAllTests() { + console.log('🚀 Starting process pagination tests...\n'); + + try { + await testNewOutputBehavior(); + await testAbsoluteOffset(); + await testTailBehavior(); + await testLengthLimit(); + await testRuntimeInfo(); + await testInteractTruncation(); + await testReReadOutput(); + + console.log('\n🎉 All pagination tests passed!'); + return true; + } catch (error) { + console.error('\n❌ Test failed:', error.message); + console.error(error.stack); + return false; + } +} + +runAllTests() + .then(success => process.exit(success ? 0 : 1)) + .catch(error => { + console.error('Test error:', error); + process.exit(1); + });