@@ -46,6 +46,8 @@ export interface BackgroundProcess {
4646 outputLock : AsyncMutex ;
4747 /** Tracks how many times getOutput() has been called (for polling detection) */
4848 getOutputCallCount : number ;
49+ /** Buffer for incomplete lines (no trailing newline) from previous read */
50+ incompleteLineBuffer : string ;
4951}
5052
5153/**
@@ -222,6 +224,7 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
222224 outputBytesRead : 0 ,
223225 outputLock : new AsyncMutex ( ) ,
224226 getOutputCallCount : 0 ,
227+ incompleteLineBuffer : "" ,
225228 } ;
226229
227230 // Store process in map
@@ -331,6 +334,7 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
331334 outputBytesRead : 0 ,
332335 outputLock : new AsyncMutex ( ) ,
333336 getOutputCallCount : 0 ,
337+ incompleteLineBuffer : "" ,
334338 } ;
335339
336340 // Store process in map
@@ -504,10 +508,10 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
504508 }
505509 }
506510
507- // Apply filtering to output, returns filtered result
508- const applyFilter = ( raw : string ) : string => {
509- if ( ! filterRegex ) return raw ;
510- const lines = raw . split ( "\n" ) ;
511+ // Apply filtering to complete lines only
512+ // Incomplete line fragments (no trailing newline) are kept in buffer for next read
513+ const applyFilter = ( lines : string [ ] ) : string => {
514+ if ( ! filterRegex ) return lines . join ( "\n" ) ;
511515 const filtered = filterExclude
512516 ? lines . filter ( ( line ) => ! filterRegex . test ( line ) )
513517 : lines . filter ( ( line ) => filterRegex . test ( line ) ) ;
@@ -521,6 +525,9 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
521525 let accumulatedRaw = "" ;
522526 let currentStatus = proc . status ;
523527
528+ // Track the previous buffer to prepend to accumulated output
529+ const previousBuffer = proc . incompleteLineBuffer ;
530+
524531 while ( true ) {
525532 // Read new content via the handle (works for both local and SSH runtimes)
526533 // Output is already unified in output.log (stdout + stderr via 2>&1)
@@ -534,16 +541,25 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
534541 const refreshedProc = await this . getProcess ( processId ) ;
535542 currentStatus = refreshedProc ?. status ?? proc . status ;
536543
544+ // Line-buffered filtering: prepend incomplete line from previous call
545+ const rawWithBuffer = previousBuffer + accumulatedRaw ;
546+ const allLines = rawWithBuffer . split ( "\n" ) ;
547+
548+ // Last element is incomplete if content doesn't end with newline
549+ const hasTrailingNewline = rawWithBuffer . endsWith ( "\n" ) ;
550+ const completeLines = hasTrailingNewline ? allLines . slice ( 0 , - 1 ) : allLines . slice ( 0 , - 1 ) ;
551+ const incompleteLine = hasTrailingNewline ? "" : allLines [ allLines . length - 1 ] ;
552+
537553 // When using filter_exclude, check if we have meaningful (non-excluded) output
538- // If all new output matches the exclusion pattern, keep waiting
539- const filteredOutput = applyFilter ( accumulatedRaw ) ;
554+ // Only consider complete lines for filtering - fragments can't match patterns
555+ const filteredOutput = applyFilter ( completeLines ) ;
540556 const hasMeaningfulOutput = filterExclude
541557 ? filteredOutput . trim ( ) . length > 0
542- : accumulatedRaw . length > 0 ;
558+ : completeLines . length > 0 || incompleteLine . length > 0 ;
543559
544560 // Return immediately if:
545561 // 1. We have meaningful output (after filtering if filter_exclude is set)
546- // 2. Process is no longer running (exited/killed/failed)
562+ // 2. Process is no longer running (exited/killed/failed) - flush buffer
547563 // 3. Timeout elapsed
548564 // 4. Abort signal received (user sent a new message)
549565 if ( hasMeaningfulOutput || currentStatus !== "running" ) {
@@ -569,14 +585,52 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
569585 await new Promise ( ( resolve ) => setTimeout ( resolve , pollIntervalMs ) ) ;
570586 }
571587
572- log . debug ( `BackgroundProcessManager.getOutput: read rawLen=${ accumulatedRaw . length } ` ) ;
588+ // Final line processing with buffer from previous call
589+ const rawWithBuffer = previousBuffer + accumulatedRaw ;
590+ const allLines = rawWithBuffer . split ( "\n" ) ;
591+ const hasTrailingNewline = rawWithBuffer . endsWith ( "\n" ) ;
592+
593+ // On process exit, include incomplete line; otherwise keep it buffered
594+ const linesToReturn =
595+ currentStatus !== "running"
596+ ? allLines . filter ( ( l ) => l . length > 0 ) // Include all non-empty lines on exit
597+ : hasTrailingNewline
598+ ? allLines . slice ( 0 , - 1 )
599+ : allLines . slice ( 0 , - 1 ) ;
600+
601+ // Update buffer for next call (clear on exit, keep incomplete line otherwise)
602+ proc . incompleteLineBuffer =
603+ currentStatus === "running" && ! hasTrailingNewline ? allLines [ allLines . length - 1 ] : "" ;
573604
574- const filteredOutput = applyFilter ( accumulatedRaw ) ;
605+ log . debug (
606+ `BackgroundProcessManager.getOutput: read rawLen=${ accumulatedRaw . length } , completeLines=${ linesToReturn . length } `
607+ ) ;
608+
609+ const filteredOutput = applyFilter ( linesToReturn ) ;
575610
576611 // Suggest filter_exclude if polling too frequently on a running process
577612 const shouldSuggestFilterExclude =
578613 callCount >= 3 && ! filterExclude && currentStatus === "running" ;
579614
615+ // Suggest better pattern if using filter_exclude but still polling frequently
616+ const shouldSuggestBetterPattern =
617+ callCount >= 3 && filterExclude && currentStatus === "running" ;
618+
619+ let note : string | undefined ;
620+ if ( shouldSuggestFilterExclude ) {
621+ note =
622+ "STOP POLLING. You've called bash_output 3+ times on this process. " +
623+ "This wastes tokens and clutters the conversation. " +
624+ "Instead, make ONE call with: filter='⏳|progress|waiting|\\\\\\.\\\\\\.\\\\\\.', " +
625+ "filter_exclude=true, timeout_secs=120. This blocks until meaningful output arrives." ;
626+ } else if ( shouldSuggestBetterPattern ) {
627+ note =
628+ "You're using filter_exclude but still polling frequently. " +
629+ "Your filter pattern may not be matching the actual output. " +
630+ "Try a broader pattern like: filter='\\\\.|\\\\d+%|running|progress|pending|⏳|waiting'. " +
631+ "Wait for the FULL timeout before checking again." ;
632+ }
633+
580634 return {
581635 success : true ,
582636 status : currentStatus ,
@@ -586,12 +640,7 @@ export class BackgroundProcessManager extends EventEmitter<BackgroundProcessMana
586640 ? ( ( await this . getProcess ( processId ) ) ?. exitCode ?? undefined )
587641 : undefined ,
588642 elapsed_ms : Date . now ( ) - startTime ,
589- note : shouldSuggestFilterExclude
590- ? "STOP POLLING. You've called bash_output 3+ times on this process. " +
591- "This wastes tokens and clutters the conversation. " +
592- "Instead, make ONE call with: filter='⏳|progress|waiting|\\\\.\\\\.\\\\.', " +
593- "filter_exclude=true, timeout_secs=120. This blocks until meaningful output arrives."
594- : undefined ,
643+ note,
595644 } ;
596645 }
597646
0 commit comments