@@ -297,6 +297,8 @@ export const BashTool = Tool.define(
297297 const fs = yield * AppFileSystem . Service
298298 const plugin = yield * Plugin . Service
299299
300+ const exempt = new Set ( ( yield * plugin . trigger ( "bash.commands" , { } , { noTimeout : [ ] as string [ ] } ) ) . noTimeout )
301+
300302 const cygpath = Effect . fn ( "BashTool.cygpath" ) ( function * ( shell : string , text : string ) {
301303 const lines = yield * spawner
302304 . lines ( ChildProcess . make ( shell , [ "-lc" , 'cygpath -w -- "$1"' , "_" , text ] ) )
@@ -378,6 +380,7 @@ export const BashTool = Tool.define(
378380 env : NodeJS . ProcessEnv
379381 timeout : number
380382 description : string
383+ raw ?: boolean
381384 } ,
382385 ctx : Tool . Context ,
383386 ) {
@@ -415,13 +418,23 @@ export const BashTool = Tool.define(
415418 return Effect . sync ( ( ) => ctx . abort . removeEventListener ( "abort" , handler ) )
416419 } )
417420
418- const timeout = Effect . sleep ( `${ input . timeout + 100 } millis` )
419-
420- const exit = yield * Effect . raceAll ( [
421- handle . exitCode . pipe ( Effect . map ( ( code ) => ( { kind : "exit" as const , code } ) ) ) ,
421+ // timeout === 0 means no timeout (scripts with plugin callbacks can run indefinitely)
422+ const races : Effect . Effect < { kind : "exit" | "abort" | "timeout" ; code : number | null } > [ ] = [
423+ handle . exitCode . pipe (
424+ Effect . map ( ( code ) => ( { kind : "exit" as const , code } ) ) ,
425+ Effect . orElseSucceed ( ( ) => ( { kind : "exit" as const , code : - 1 } ) ) ,
426+ ) ,
422427 abort . pipe ( Effect . map ( ( ) => ( { kind : "abort" as const , code : null } ) ) ) ,
423- timeout . pipe ( Effect . map ( ( ) => ( { kind : "timeout" as const , code : null } ) ) ) ,
424- ] )
428+ ]
429+ if ( input . timeout > 0 ) {
430+ races . push (
431+ Effect . sleep ( `${ input . timeout + 100 } millis` ) . pipe (
432+ Effect . map ( ( ) => ( { kind : "timeout" as const , code : null } ) ) ,
433+ ) ,
434+ )
435+ }
436+
437+ const exit = yield * Effect . raceAll ( races )
425438
426439 if ( exit . kind === "abort" ) {
427440 aborted = true
@@ -449,6 +462,8 @@ export const BashTool = Tool.define(
449462 output : preview ( output ) ,
450463 exit : code ,
451464 description : input . description ,
465+ // Signal Tool.wrap to skip truncation for unbounded plugin commands
466+ ...( input . raw && { truncated : false } ) ,
452467 } ,
453468 output,
454469 }
@@ -480,13 +495,23 @@ export const BashTool = Tool.define(
480495 if ( params . timeout !== undefined && params . timeout < 0 ) {
481496 throw new Error ( `Invalid timeout value: ${ params . timeout } . Timeout must be a positive number.` )
482497 }
483- const timeout = params . timeout ?? DEFAULT_TIMEOUT
484498 const ps = PS . has ( name )
485499 const root = yield * parse ( params . command , ps )
486500 const scan = yield * collect ( root , cwd , ps , shell )
487501 if ( ! Instance . containsPath ( cwd ) ) scan . dirs . add ( cwd )
488502 yield * ask ( ctx , scan )
489503
504+ // Plugin-registered commands that trigger long-running callbacks need no timeout.
505+ // The bash.commands hook lets plugins declare which command names should disable
506+ // the timeout (e.g., a plugin shipping a CLI binary that calls back into the AI).
507+ const unbounded =
508+ exempt . size > 0 &&
509+ commands ( root ) . some ( ( node ) => {
510+ const bin = node . childForFieldName ( "name" ) ?? node . firstChild
511+ return bin !== null && exempt . has ( bin . text )
512+ } )
513+ const timeout = unbounded ? 0 : ( params . timeout ?? DEFAULT_TIMEOUT )
514+
490515 return yield * run (
491516 {
492517 shell,
@@ -496,6 +521,7 @@ export const BashTool = Tool.define(
496521 env : yield * shellEnv ( ctx , cwd ) ,
497522 timeout,
498523 description : params . description ,
524+ raw : unbounded ,
499525 } ,
500526 ctx ,
501527 )
0 commit comments