@@ -32,6 +32,11 @@ import { runStdinStreamMode } from "./stdin-stream.js"
3232
3333const __dirname = path . dirname ( fileURLToPath ( import . meta. url ) )
3434const ROO_MODEL_WARMUP_TIMEOUT_MS = 10_000
35+ const SIGNAL_ONLY_EXIT_KEEPALIVE_MS = 60_000
36+
37+ function normalizeError ( error : unknown ) : Error {
38+ return error instanceof Error ? error : new Error ( String ( error ) )
39+ }
3540
3641async function warmRooModels ( host : ExtensionHost ) : Promise < void > {
3742 await new Promise < void > ( ( resolve , reject ) => {
@@ -251,6 +256,12 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
251256 process . exit ( 1 )
252257 }
253258
259+ if ( flagOptions . signalOnlyExit && ! flagOptions . stdinPromptStream ) {
260+ console . error ( "[CLI] Error: --signal-only-exit requires --stdin-prompt-stream" )
261+ console . error ( "[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream --signal-only-exit" )
262+ process . exit ( 1 )
263+ }
264+
254265 if ( flagOptions . stdinPromptStream && outputFormat !== "stream-json" ) {
255266 console . error ( "[CLI] Error: --stdin-prompt-stream requires --output-format=stream-json" )
256267 console . error ( "[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]" )
@@ -323,11 +334,15 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
323334 }
324335 } else {
325336 const useJsonOutput = outputFormat === "json" || outputFormat === "stream-json"
337+ const signalOnlyExit = flagOptions . signalOnlyExit
326338
327339 extensionHostOptions . disableOutput = useJsonOutput
328340
329341 const host = new ExtensionHost ( extensionHostOptions )
330342 let streamRequestId : string | undefined
343+ let keepAliveInterval : NodeJS . Timeout | undefined
344+ let isShuttingDown = false
345+ let hostDisposed = false
331346
332347 const jsonEmitter = useJsonOutput
333348 ? new JsonEventEmitter ( {
@@ -336,17 +351,110 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
336351 } )
337352 : null
338353
354+ const emitRuntimeError = ( error : Error , source ?: string ) => {
355+ const errorMessage = source ? `${ source } : ${ error . message } ` : error . message
356+
357+ if ( useJsonOutput ) {
358+ const errorEvent = { type : "error" , id : Date . now ( ) , content : errorMessage }
359+ process . stdout . write ( JSON . stringify ( errorEvent ) + "\n" )
360+ return
361+ }
362+
363+ console . error ( "[CLI] Error:" , errorMessage )
364+ console . error ( error . stack )
365+ }
366+
367+ const clearKeepAliveInterval = ( ) => {
368+ if ( ! keepAliveInterval ) {
369+ return
370+ }
371+
372+ clearInterval ( keepAliveInterval )
373+ keepAliveInterval = undefined
374+ }
375+
376+ const ensureKeepAliveInterval = ( ) => {
377+ if ( ! signalOnlyExit || keepAliveInterval ) {
378+ return
379+ }
380+
381+ keepAliveInterval = setInterval ( ( ) => { } , SIGNAL_ONLY_EXIT_KEEPALIVE_MS )
382+ }
383+
384+ const disposeHost = async ( ) => {
385+ if ( hostDisposed ) {
386+ return
387+ }
388+
389+ hostDisposed = true
390+ jsonEmitter ?. detach ( )
391+ await host . dispose ( )
392+ }
393+
394+ const onSigint = ( ) => {
395+ void shutdown ( "SIGINT" , 130 )
396+ }
397+
398+ const onSigterm = ( ) => {
399+ void shutdown ( "SIGTERM" , 143 )
400+ }
401+
402+ const onUncaughtException = ( error : Error ) => {
403+ emitRuntimeError ( error , "uncaughtException" )
404+
405+ if ( signalOnlyExit ) {
406+ return
407+ }
408+
409+ void shutdown ( "uncaughtException" , 1 )
410+ }
411+
412+ const onUnhandledRejection = ( reason : unknown ) => {
413+ const error = normalizeError ( reason )
414+ emitRuntimeError ( error , "unhandledRejection" )
415+
416+ if ( signalOnlyExit ) {
417+ return
418+ }
419+
420+ void shutdown ( "unhandledRejection" , 1 )
421+ }
422+
423+ const parkUntilSignal = async ( reason : string ) : Promise < never > => {
424+ ensureKeepAliveInterval ( )
425+
426+ if ( ! useJsonOutput ) {
427+ console . error ( `[CLI] ${ reason } (--signal-only-exit active; waiting for SIGINT/SIGTERM).` )
428+ }
429+
430+ await new Promise < void > ( ( ) => { } )
431+ throw new Error ( "unreachable" )
432+ }
433+
339434 async function shutdown ( signal : string , exitCode : number ) : Promise < void > {
435+ if ( isShuttingDown ) {
436+ return
437+ }
438+
439+ isShuttingDown = true
440+ process . off ( "SIGINT" , onSigint )
441+ process . off ( "SIGTERM" , onSigterm )
442+ process . off ( "uncaughtException" , onUncaughtException )
443+ process . off ( "unhandledRejection" , onUnhandledRejection )
444+ clearKeepAliveInterval ( )
445+
340446 if ( ! useJsonOutput ) {
341447 console . log ( `\n[CLI] Received ${ signal } , shutting down...` )
342448 }
343- jsonEmitter ?. detach ( )
344- await host . dispose ( )
449+
450+ await disposeHost ( )
345451 process . exit ( exitCode )
346452 }
347453
348- process . on ( "SIGINT" , ( ) => shutdown ( "SIGINT" , 130 ) )
349- process . on ( "SIGTERM" , ( ) => shutdown ( "SIGTERM" , 143 ) )
454+ process . on ( "SIGINT" , onSigint )
455+ process . on ( "SIGTERM" , onSigterm )
456+ process . on ( "uncaughtException" , onUncaughtException )
457+ process . on ( "unhandledRejection" , onUnhandledRejection )
350458
351459 try {
352460 await host . activate ( )
@@ -381,25 +489,29 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
381489 await host . runTask ( prompt ! )
382490 }
383491
384- jsonEmitter ?. detach ( )
385- await host . dispose ( )
492+ await disposeHost ( )
493+
494+ if ( signalOnlyExit ) {
495+ await parkUntilSignal ( "Task loop completed" )
496+ }
497+
498+ process . off ( "SIGINT" , onSigint )
499+ process . off ( "SIGTERM" , onSigterm )
500+ process . off ( "uncaughtException" , onUncaughtException )
501+ process . off ( "unhandledRejection" , onUnhandledRejection )
386502 process . exit ( 0 )
387503 } catch ( error ) {
388- const errorMessage = error instanceof Error ? error . message : String ( error )
504+ emitRuntimeError ( normalizeError ( error ) )
505+ await disposeHost ( )
389506
390- if ( useJsonOutput ) {
391- const errorEvent = { type : "error" , id : Date . now ( ) , content : errorMessage }
392- process . stdout . write ( JSON . stringify ( errorEvent ) + "\n" )
393- } else {
394- console . error ( "[CLI] Error:" , errorMessage )
395-
396- if ( error instanceof Error ) {
397- console . error ( error . stack )
398- }
507+ if ( signalOnlyExit ) {
508+ await parkUntilSignal ( "Task loop failed" )
399509 }
400510
401- jsonEmitter ?. detach ( )
402- await host . dispose ( )
511+ process . off ( "SIGINT" , onSigint )
512+ process . off ( "SIGTERM" , onSigterm )
513+ process . off ( "uncaughtException" , onUncaughtException )
514+ process . off ( "unhandledRejection" , onUnhandledRejection )
403515 process . exit ( 1 )
404516 }
405517 }
0 commit comments