@@ -8,6 +8,7 @@ import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "open
88import { launchNotifyScript } from "./common/notify" ;
99import { buildThinkingRequestOptions } from "./common/openai-thinking" ;
1010import { DEEPSEEK_V4_MODELS , supportsMultimodal } from "./common/model-capabilities" ;
11+ import { readTextFileWithMetadata } from "./common/file-utils" ;
1112import {
1213 getCompactPrompt ,
1314 getDefaultSkillPrompt ,
@@ -60,6 +61,7 @@ export type {
6061const MAX_SESSION_ENTRIES = 50 ;
6162const MAX_PROJECT_CODE_LENGTH = 64 ;
6263const PROJECT_CODE_HASH_LENGTH = 16 ;
64+ const BACKGROUND_FAILURE_LOG_TAIL_CHARS = 4000 ;
6365const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024 ;
6466const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024 ;
6567
@@ -330,6 +332,7 @@ export class SessionManager {
330332 private activePromptController : AbortController | null = null ;
331333 private readonly sessionControllers = new Map < string , AbortController > ( ) ;
332334 private readonly processTimeoutControls = new Map < string , ProcessTimeoutControl > ( ) ;
335+ private readonly liveProcessKeys = new Set < string > ( ) ;
333336 private readonly toolExecutor : ToolExecutor ;
334337 private readonly mcpManager = new McpManager ( ) ;
335338 private mcpToolDefinitions : ToolDefinition [ ] = [ ] ;
@@ -379,6 +382,7 @@ export class SessionManager {
379382 sessionController . abort ( ) ;
380383 }
381384 }
385+ this . killLiveProcesses ( ) ;
382386 this . sessionControllers . clear ( ) ;
383387 this . processTimeoutControls . clear ( ) ;
384388 this . mcpManager . disconnect ( ) ;
@@ -1525,7 +1529,9 @@ ${skillMd}
15251529 const killedPids : number [ ] = [ ] ;
15261530 const failedPids : number [ ] = [ ] ;
15271531 for ( const pid of processIds ) {
1528- this . processTimeoutControls . delete ( this . getProcessControlKey ( sessionId , pid ) ) ;
1532+ const processControlKey = this . getProcessControlKey ( sessionId , pid ) ;
1533+ this . processTimeoutControls . delete ( processControlKey ) ;
1534+ this . liveProcessKeys . delete ( processControlKey ) ;
15291535 if ( killProcessTree ( pid , "SIGKILL" ) ) {
15301536 killedPids . push ( pid ) ;
15311537 continue ;
@@ -1892,21 +1898,11 @@ ${skillMd}
18921898 const processIds = options . processIds ?? [ ] ;
18931899 for ( const pid of processIds ) {
18941900 const processControlKey = this . getProcessControlKey ( sessionId , pid ) ;
1895- if ( ! this . processTimeoutControls . has ( processControlKey ) ) {
1901+ if ( ! this . processTimeoutControls . has ( processControlKey ) && ! this . liveProcessKeys . has ( processControlKey ) ) {
18961902 continue ;
18971903 }
18981904
1899- const killedGroup = killProcessTree ( pid , "SIGKILL" ) ;
1900- if ( killedGroup ) {
1901- this . processTimeoutControls . delete ( processControlKey ) ;
1902- continue ;
1903- }
1904- try {
1905- process . kill ( pid , "SIGKILL" ) ;
1906- } catch {
1907- // ignore process-kill failures during cleanup
1908- }
1909- this . processTimeoutControls . delete ( processControlKey ) ;
1905+ this . killTrackedProcess ( processControlKey , pid ) ;
19101906 }
19111907
19121908 clearSessionState ( sessionId ) ;
@@ -2172,6 +2168,7 @@ ${skillMd}
21722168 onProcessExit : ( pid ) => this . removeSessionProcess ( sessionId , pid ) ,
21732169 onProcessStdout : ( pid , chunk ) => this . onProcessStdout ?.( Number ( pid ) , chunk ) ,
21742170 onProcessTimeoutControl : ( pid , control ) => this . setSessionProcessTimeoutControl ( sessionId , pid , control ) ,
2171+ onBackgroundProcessComplete : ( completion ) => this . addBackgroundProcessCompletionMessage ( sessionId , completion ) ,
21752172 onBeforeFileMutation : ( filePath ) => this . prepareFileMutationCheckpoint ( sessionId , filePath ) ,
21762173 onAfterFileMutation : ( filePath ) => this . recordFileMutationCheckpoint ( sessionId , filePath ) ,
21772174 shouldStop : ( ) => this . isInterrupted ( sessionId ) ,
@@ -2666,6 +2663,7 @@ ${skillMd}
26662663
26672664 private addSessionProcess ( sessionId : string , processId : string | number , command : string ) : void {
26682665 const now = new Date ( ) . toISOString ( ) ;
2666+ this . liveProcessKeys . add ( this . getProcessControlKey ( sessionId , processId ) ) ;
26692667 this . updateSessionEntry ( sessionId , ( entry ) => {
26702668 const processes = new Map ( entry . processes ?? [ ] ) ;
26712669 processes . set ( String ( processId ) , { startTime : now , command } ) ;
@@ -2677,9 +2675,86 @@ ${skillMd}
26772675 } ) ;
26782676 }
26792677
2678+ private addBackgroundProcessCompletionMessage (
2679+ sessionId : string ,
2680+ completion : {
2681+ command : string ;
2682+ outputPath : string ;
2683+ ok : boolean ;
2684+ exitCode : number | null ;
2685+ signal : string | null ;
2686+ error ?: string ;
2687+ completedAtMs : number ;
2688+ startedAtMs : number ;
2689+ }
2690+ ) : void {
2691+ const status = completion . ok ? "completed" : "failed" ;
2692+ const exitText =
2693+ completion . exitCode !== null
2694+ ? `exit code ${ completion . exitCode } `
2695+ : completion . signal
2696+ ? `signal ${ completion . signal } `
2697+ : completion . error || "unknown status" ;
2698+ const durationMs = Math . max ( 0 , completion . completedAtMs - completion . startedAtMs ) ;
2699+ const baseContent =
2700+ `Background command "${ completion . command } " ${ status } with ${ exitText } ` +
2701+ `after ${ this . formatBackgroundDuration ( durationMs ) } . Output: ${ completion . outputPath } ` ;
2702+ const logTail = completion . ok ? null : this . buildBackgroundFailureLogTailSlice ( completion . outputPath ) ;
2703+ const content = logTail ? `${ baseContent } \n${ logTail } ` : baseContent ;
2704+ this . addSessionSystemMessage ( sessionId , content , true ) ;
2705+ }
2706+
2707+ private buildBackgroundFailureLogTailSlice ( outputPath : string ) : string | null {
2708+ const tail = this . readTextFileTail ( outputPath , BACKGROUND_FAILURE_LOG_TAIL_CHARS ) ;
2709+ if ( ! tail || ! tail . content ) {
2710+ return null ;
2711+ }
2712+ const prefix = tail . truncated ? `(${ tail . totalBytes } bytes)...\n` : "" ;
2713+ return [
2714+ `<background_task_failure_log path="${ outputPath } ">` ,
2715+ `${ prefix } ${ tail . content } ` ,
2716+ "</background_task_failure_log>" ,
2717+ ] . join ( "\n" ) ;
2718+ }
2719+
2720+ private readTextFileTail (
2721+ filePath : string ,
2722+ maxChars : number
2723+ ) : { content : string ; totalBytes : number ; truncated : boolean } | null {
2724+ try {
2725+ const stat = fs . statSync ( filePath ) ;
2726+ if ( ! stat . isFile ( ) || stat . size <= 0 ) {
2727+ return null ;
2728+ }
2729+ const content = readTextFileWithMetadata ( filePath ) . content ;
2730+ return {
2731+ content : content . slice ( - maxChars ) . trimEnd ( ) ,
2732+ totalBytes : stat . size ,
2733+ truncated : content . length > maxChars ,
2734+ } ;
2735+ } catch {
2736+ return null ;
2737+ }
2738+ }
2739+
2740+ private formatBackgroundDuration ( durationMs : number ) : string {
2741+ if ( durationMs < 1000 ) {
2742+ return `${ durationMs } ms` ;
2743+ }
2744+ const seconds = Math . round ( durationMs / 1000 ) ;
2745+ if ( seconds < 60 ) {
2746+ return `${ seconds } s` ;
2747+ }
2748+ const minutes = Math . floor ( seconds / 60 ) ;
2749+ const remainingSeconds = seconds % 60 ;
2750+ return remainingSeconds > 0 ? `${ minutes } m ${ remainingSeconds } s` : `${ minutes } m` ;
2751+ }
2752+
26802753 private removeSessionProcess ( sessionId : string , processId : string | number ) : void {
26812754 const now = new Date ( ) . toISOString ( ) ;
2682- this . processTimeoutControls . delete ( this . getProcessControlKey ( sessionId , processId ) ) ;
2755+ const processControlKey = this . getProcessControlKey ( sessionId , processId ) ;
2756+ this . processTimeoutControls . delete ( processControlKey ) ;
2757+ this . liveProcessKeys . delete ( processControlKey ) ;
26832758 this . updateSessionEntry ( sessionId , ( entry ) => {
26842759 const processes = new Map ( entry . processes ?? [ ] ) ;
26852760 processes . delete ( String ( processId ) ) ;
@@ -2742,6 +2817,37 @@ ${skillMd}
27422817 return `${ sessionId } :${ String ( processId ) } ` ;
27432818 }
27442819
2820+ private killLiveProcesses ( ) : void {
2821+ for ( const processControlKey of Array . from ( this . liveProcessKeys ) ) {
2822+ const processId = this . getProcessIdFromControlKey ( processControlKey ) ;
2823+ if ( processId === null ) {
2824+ this . liveProcessKeys . delete ( processControlKey ) ;
2825+ continue ;
2826+ }
2827+ this . killTrackedProcess ( processControlKey , processId ) ;
2828+ }
2829+ }
2830+
2831+ private killTrackedProcess ( processControlKey : string , processId : number ) : void {
2832+ const killedGroup = killProcessTree ( processId , "SIGKILL" ) ;
2833+ if ( ! killedGroup ) {
2834+ try {
2835+ process . kill ( processId , "SIGKILL" ) ;
2836+ } catch {
2837+ // Ignore process-kill failures during cleanup.
2838+ }
2839+ }
2840+ this . processTimeoutControls . delete ( processControlKey ) ;
2841+ this . liveProcessKeys . delete ( processControlKey ) ;
2842+ }
2843+
2844+ private getProcessIdFromControlKey ( processControlKey : string ) : number | null {
2845+ const separatorIndex = processControlKey . lastIndexOf ( ":" ) ;
2846+ const rawProcessId = separatorIndex >= 0 ? processControlKey . slice ( separatorIndex + 1 ) : processControlKey ;
2847+ const processId = Number ( rawProcessId ) ;
2848+ return Number . isInteger ( processId ) && processId > 0 ? processId : null ;
2849+ }
2850+
27452851 private getProcessIds ( processes : Map < string , SessionProcessEntry > | null ) : number [ ] {
27462852 if ( ! processes ) {
27472853 return [ ] ;
0 commit comments