11import z from "zod"
22import os from "os"
3- import { spawn } from "child_process"
43import { Tool } from "./tool"
54import path from "path"
65import DESCRIPTION from "./bash.txt"
@@ -18,6 +17,9 @@ import { Shell } from "@/shell/shell"
1817import { BashArity } from "@/permission/arity"
1918import { Truncate } from "./truncate"
2019import { Plugin } from "@/plugin"
20+ import { Cause , Effect , Exit , Stream } from "effect"
21+ import { ChildProcess , ChildProcessSpawner } from "effect/unstable/process"
22+ import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
2123
2224const MAX_METADATA_LENGTH = 30_000
2325const DEFAULT_TIMEOUT = Flag . OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -293,27 +295,26 @@ async function shellEnv(ctx: Tool.Context, cwd: string) {
293295 }
294296}
295297
296- function launch ( shell : string , name : string , command : string , cwd : string , env : NodeJS . ProcessEnv ) {
298+ function cmd ( shell : string , name : string , command : string , cwd : string , env : NodeJS . ProcessEnv ) {
297299 if ( process . platform === "win32" && PS . has ( name ) ) {
298- return spawn ( shell , [ "-NoLogo" , "-NoProfile" , "-NonInteractive" , "-Command" , command ] , {
300+ return ChildProcess . make ( shell , [ "-NoLogo" , "-NoProfile" , "-NonInteractive" , "-Command" , command ] , {
299301 cwd,
300302 env,
301- stdio : [ "ignore" , "pipe" , "pipe" ] ,
303+ stdin : "ignore" ,
302304 detached : false ,
303- windowsHide : true ,
304305 } )
305306 }
306307
307- return spawn ( command , {
308+ return ChildProcess . make ( command , [ ] , {
308309 shell,
309310 cwd,
310311 env,
311- stdio : [ "ignore" , "pipe" , "pipe" ] ,
312+ stdin : "ignore" ,
312313 detached : process . platform !== "win32" ,
313- windowsHide : process . platform === "win32" ,
314314 } )
315315}
316316
317+
317318async function run (
318319 input : {
319320 shell : string
@@ -326,8 +327,9 @@ async function run(
326327 } ,
327328 ctx : Tool . Context ,
328329) {
329- const proc = launch ( input . shell , input . name , input . command , input . cwd , input . env )
330330 let output = ""
331+ let expired = false
332+ let aborted = false
331333
332334 ctx . metadata ( {
333335 metadata : {
@@ -336,76 +338,78 @@ async function run(
336338 } ,
337339 } )
338340
339- const append = ( chunk : Buffer ) => {
340- output += chunk . toString ( )
341- ctx . metadata ( {
342- metadata : {
343- output : preview ( output ) ,
344- description : input . description ,
345- } ,
346- } )
347- }
348-
349- proc . stdout ?. on ( "data" , append )
350- proc . stderr ?. on ( "data" , append )
351-
352- let expired = false
353- let aborted = false
354- let exited = false
355-
356- const kill = ( ) => Shell . killTree ( proc , { exited : ( ) => exited } )
357-
358- if ( ctx . abort . aborted ) {
359- aborted = true
360- await kill ( )
361- }
341+ const exit = await CrossSpawnSpawner . runPromiseExit ( ( spawner ) =>
342+ Effect . gen ( function * ( ) {
343+ const handle = yield * spawner . spawn (
344+ cmd ( input . shell , input . name , input . command , input . cwd , input . env ) ,
345+ )
362346
363- const abort = ( ) => {
364- aborted = true
365- void kill ( )
366- }
347+ yield * Effect . forkScoped (
348+ Stream . runForEach (
349+ Stream . decodeText ( handle . all ) ,
350+ ( chunk ) =>
351+ Effect . sync ( ( ) => {
352+ output += chunk
353+ ctx . metadata ( {
354+ metadata : {
355+ output : preview ( output ) ,
356+ description : input . description ,
357+ } ,
358+ } )
359+ } ) ,
360+ ) ,
361+ )
367362
368- ctx . abort . addEventListener ( "abort" , abort , { once : true } )
369- const timer = setTimeout ( ( ) => {
370- expired = true
371- void kill ( )
372- } , input . timeout + 100 )
363+ const abort = Effect . callback < void > ( ( resume ) => {
364+ if ( ctx . abort . aborted ) return resume ( Effect . void )
365+ const handler = ( ) => resume ( Effect . void )
366+ ctx . abort . addEventListener ( "abort" , handler , { once : true } )
367+ return Effect . sync ( ( ) => ctx . abort . removeEventListener ( "abort" , handler ) )
368+ } )
373369
374- await new Promise < void > ( ( resolve , reject ) => {
375- const cleanup = ( ) => {
376- clearTimeout ( timer )
377- ctx . abort . removeEventListener ( "abort" , abort )
378- }
370+ const timeout = Effect . sleep ( `${ input . timeout + 100 } millis` )
379371
380- proc . once ( "exit" , ( ) => {
381- exited = true
382- } )
372+ const exit = yield * Effect . raceAll ( [
373+ handle . exitCode . pipe ( Effect . map ( ( code ) => ( { kind : "exit" as const , code } ) ) ) ,
374+ abort . pipe ( Effect . map ( ( ) => ( { kind : "abort" as const , code : null } ) ) ) ,
375+ timeout . pipe ( Effect . map ( ( ) => ( { kind : "timeout" as const , code : null } ) ) ) ,
376+ ] )
383377
384- proc . once ( "close" , ( ) => {
385- exited = true
386- cleanup ( )
387- resolve ( )
388- } )
378+ if ( exit . kind === "abort" ) {
379+ aborted = true
380+ yield * handle . kill ( { forceKillAfter : "3 seconds" } ) . pipe ( Effect . orDie )
381+ }
382+ if ( exit . kind === "timeout" ) {
383+ expired = true
384+ yield * handle . kill ( { forceKillAfter : "3 seconds" } ) . pipe ( Effect . orDie )
385+ }
389386
390- proc . once ( "error" , ( error ) => {
391- exited = true
392- cleanup ( )
393- reject ( error )
394- } )
395- } )
387+ return exit . kind === "exit" ? exit . code : null
388+ } ) . pipe (
389+ Effect . scoped ,
390+ Effect . orDie ,
391+ ) ,
392+ )
393+
394+ let code : number | null = null
395+ if ( Exit . isSuccess ( exit ) ) {
396+ code = exit . value
397+ } else if ( ! Cause . hasInterruptsOnly ( exit . cause ) ) {
398+ throw Cause . squash ( exit . cause )
399+ }
396400
397- const metadata : string [ ] = [ ]
398- if ( expired ) metadata . push ( `bash tool terminated command after exceeding timeout ${ input . timeout } ms` )
399- if ( aborted ) metadata . push ( "User aborted the command" )
400- if ( metadata . length > 0 ) {
401- output += "\n\n<bash_metadata>\n" + metadata . join ( "\n" ) + "\n</bash_metadata>"
401+ const meta : string [ ] = [ ]
402+ if ( expired ) meta . push ( `bash tool terminated command after exceeding timeout ${ input . timeout } ms` )
403+ if ( aborted ) meta . push ( "User aborted the command" )
404+ if ( meta . length > 0 ) {
405+ output += "\n\n<bash_metadata>\n" + meta . join ( "\n" ) + "\n</bash_metadata>"
402406 }
403407
404408 return {
405409 title : input . description ,
406410 metadata : {
407411 output : preview ( output ) ,
408- exit : proc . exitCode ,
412+ exit : code ,
409413 description : input . description ,
410414 } ,
411415 output,
0 commit comments