2026-05-02 23:18:48 +00:00
import { Effect , Stream } from "effect"
2026-03-30 03:10:01 +00:00
import os from "os"
2026-04-16 01:09:06 +00:00
import { createWriteStream } from "node:fs"
2026-04-16 03:56:54 +00:00
import * as Tool from "./tool"
2026-01-02 23:48:26 +00:00
import path from "path"
2026-04-27 18:33:33 +00:00
import * as Log from "@opencode-ai/core/util/log"
2026-05-02 04:02:52 +00:00
import { containsPath , type InstanceContext } from "../project/instance-context"
2026-05-02 23:18:48 +00:00
import { InstanceState } from "@/effect/instance-state"
2025-10-31 19:07:36 +00:00
import { lazy } from "@/util/lazy"
2026-03-30 03:10:01 +00:00
import { Language , type Node } from "web-tree-sitter"
2026-01-01 22:54:11 +00:00
2026-04-25 14:59:17 +00:00
import { AppFileSystem } from "@opencode-ai/core/filesystem"
2025-11-18 06:46:49 +00:00
import { fileURLToPath } from "url"
2026-04-27 18:33:33 +00:00
import { Config } from "@/config/config"
2026-05-14 21:19:14 +00:00
import { RuntimeFlags } from "@/effect/runtime-flags"
2025-12-12 22:11:07 +00:00
import { Shell } from "@/shell/shell"
2026-05-02 23:18:48 +00:00
import { ShellID } from "./shell/id"
2025-05-19 23:29:38 +00:00
2026-04-16 03:56:54 +00:00
import * as Truncate from "./truncate"
2026-02-03 21:18:41 +00:00
import { Plugin } from "@/plugin"
2026-04-10 17:26:31 +00:00
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
2026-05-02 23:18:48 +00:00
import { ShellPrompt , type Parameters } from "./shell/prompt"
import { BashArity } from "@/permission/arity"
export { Parameters } from "./shell/prompt"
2026-01-01 22:54:11 +00:00
2026-01-07 23:25:00 +00:00
const MAX_METADATA_LENGTH = 30 _000
2026-05-02 23:18:48 +00:00
const CWD = new Set ( [ "cd" , "chdir" , "popd" , "pushd" , "push-location" , "set-location" ] )
2026-03-30 03:10:01 +00:00
const FILES = new Set ( [
. . . CWD ,
"rm" ,
"cp" ,
"mv" ,
"mkdir" ,
"touch" ,
"chmod" ,
"chown" ,
"cat" ,
// Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir
// already hit the entries above, and alias normalization should happen in one
// place later so we do not risk double-prompting.
"get-content" ,
"set-content" ,
"add-content" ,
"copy-item" ,
"move-item" ,
"remove-item" ,
"new-item" ,
"rename-item" ,
] )
2026-05-02 23:20:15 +00:00
const CMD_FILES = new Set ( [
"copy" ,
"del" ,
"dir" ,
"erase" ,
"md" ,
"mkdir" ,
"move" ,
"rd" ,
"ren" ,
"rename" ,
"rmdir" ,
"type" ,
] )
2026-03-30 03:10:01 +00:00
const FLAGS = new Set ( [ "-destination" , "-literalpath" , "-path" ] )
const SWITCHES = new Set ( [ "-confirm" , "-debug" , "-force" , "-nonewline" , "-recurse" , "-verbose" , "-whatif" ] )
type Part = {
type : string
text : string
}
type Scan = {
dirs : Set < string >
patterns : Set < string >
always : Set < string >
}
2025-05-19 23:29:38 +00:00
2026-04-16 01:09:06 +00:00
type Chunk = {
text : string
size : number
}
2026-05-02 23:18:48 +00:00
export const log = Log . create ( { service : "shell-tool" } )
2025-08-01 01:41:48 +00:00
2025-11-18 06:46:49 +00:00
const resolveWasm = ( asset : string ) = > {
if ( asset . startsWith ( "file://" ) ) return fileURLToPath ( asset )
2025-11-18 20:06:45 +00:00
if ( asset . startsWith ( "/" ) || /^[a-z]:/i . test ( asset ) ) return asset
2025-11-18 06:46:49 +00:00
const url = new URL ( asset , import . meta . url )
return fileURLToPath ( url )
}
2026-03-30 03:10:01 +00:00
function parts ( node : Node ) {
const out : Part [ ] = [ ]
for ( let i = 0 ; i < node . childCount ; i ++ ) {
const child = node . child ( i )
if ( ! child ) continue
if ( child . type === "command_elements" ) {
for ( let j = 0 ; j < child . childCount ; j ++ ) {
const item = child . child ( j )
if ( ! item || item . type === "command_argument_sep" || item . type === "redirection" ) continue
out . push ( { type : item . type , text : item.text } )
}
continue
}
if (
child . type !== "command_name" &&
child . type !== "command_name_expr" &&
child . type !== "word" &&
child . type !== "string" &&
child . type !== "raw_string" &&
child . type !== "concatenation"
) {
continue
}
out . push ( { type : child . type , text : child.text } )
}
return out
}
function source ( node : Node ) {
return ( node . parent ? . type === "redirected_statement" ? node.parent.text : node.text ) . trim ( )
}
function commands ( node : Node ) {
return node . descendantsOfType ( "command" ) . filter ( ( child ) : child is Node = > Boolean ( child ) )
}
function unquote ( text : string ) {
if ( text . length < 2 ) return text
const first = text [ 0 ]
const last = text [ text . length - 1 ]
if ( ( first === '"' || first === "'" ) && first === last ) return text . slice ( 1 , - 1 )
return text
}
function home ( text : string ) {
if ( text === "~" ) return os . homedir ( )
if ( text . startsWith ( "~/" ) || text . startsWith ( "~\\" ) ) return path . join ( os . homedir ( ) , text . slice ( 2 ) )
return text
}
function envValue ( key : string ) {
if ( process . platform !== "win32" ) return process . env [ key ]
const name = Object . keys ( process . env ) . find ( ( item ) = > item . toLowerCase ( ) === key . toLowerCase ( ) )
return name ? process . env [ name ] : undefined
}
function auto ( key : string , cwd : string , shell : string ) {
const name = key . toUpperCase ( )
if ( name === "HOME" ) return os . homedir ( )
if ( name === "PWD" ) return cwd
if ( name === "PSHOME" ) return path . dirname ( shell )
}
function expand ( text : string , cwd : string , shell : string ) {
const out = unquote ( text )
. replace ( /\$\{env:([^}]+)\}/gi , ( _ , key : string ) = > envValue ( key ) || "" )
. replace ( /\$env:([A-Za-z_][A-Za-z0-9_]*)/gi , ( _ , key : string ) = > envValue ( key ) || "" )
. replace ( /\$(HOME|PWD|PSHOME)(?=$|[\\/])/gi , ( _ , key : string ) = > auto ( key , cwd , shell ) || "" )
return home ( out )
}
function provider ( text : string ) {
const match = text . match ( /^([A-Za-z]+)::(.*)$/ )
if ( match ) {
if ( match [ 1 ] . toLowerCase ( ) !== "filesystem" ) return
return match [ 2 ]
}
const prefix = text . match ( /^([A-Za-z]+):(.*)$/ )
if ( ! prefix ) return text
if ( prefix [ 1 ] . length === 1 ) return text
return
}
function dynamic ( text : string , ps : boolean ) {
if ( text . startsWith ( "(" ) || text . startsWith ( "@(" ) ) return true
if ( text . includes ( "$(" ) || text . includes ( "${" ) || text . includes ( "`" ) ) return true
if ( ps ) return /\$(?!env:)/i . test ( text )
return text . includes ( "$" )
}
function prefix ( text : string ) {
2026-04-16 00:45:19 +00:00
const match = /[?*[]/ . exec ( text )
2026-03-30 03:10:01 +00:00
if ( ! match ) return text
if ( match . index === 0 ) return
return text . slice ( 0 , match . index )
}
2026-05-02 23:18:48 +00:00
function pathArgs ( list : Part [ ] , ps : boolean , cmd = false ) {
2026-03-30 03:10:01 +00:00
if ( ! ps ) {
return list
. slice ( 1 )
2026-05-02 23:18:48 +00:00
. filter (
( item ) = >
! item . text . startsWith ( "-" ) &&
! ( cmd && item . text . startsWith ( "/" ) ) &&
! ( list [ 0 ] ? . text === "chmod" && item . text . startsWith ( "+" ) ) ,
)
2026-03-30 03:10:01 +00:00
. map ( ( item ) = > item . text )
}
const out : string [ ] = [ ]
let want = false
for ( const item of list . slice ( 1 ) ) {
if ( want ) {
out . push ( item . text )
want = false
continue
}
if ( item . type === "command_parameter" ) {
const flag = item . text . toLowerCase ( )
if ( SWITCHES . has ( flag ) ) continue
want = FLAGS . has ( flag )
continue
}
out . push ( item . text )
}
return out
}
function preview ( text : string ) {
if ( text . length <= MAX_METADATA_LENGTH ) return text
2026-04-16 01:09:06 +00:00
return "...\n\n" + text . slice ( - MAX_METADATA_LENGTH )
}
function tail ( text : string , maxLines : number , maxBytes : number ) {
const lines = text . split ( "\n" )
if ( lines . length <= maxLines && Buffer . byteLength ( text , "utf-8" ) <= maxBytes ) {
return {
text ,
cut : false ,
}
}
const out : string [ ] = [ ]
let bytes = 0
for ( let i = lines . length - 1 ; i >= 0 && out . length < maxLines ; i -- ) {
const size = Buffer . byteLength ( lines [ i ] , "utf-8" ) + ( out . length > 0 ? 1 : 0 )
if ( bytes + size > maxBytes ) {
if ( out . length === 0 ) {
const buf = Buffer . from ( lines [ i ] , "utf-8" )
let start = buf . length - maxBytes
if ( start < 0 ) start = 0
while ( start < buf . length && ( buf [ start ] & 0xc0 ) === 0x80 ) start ++
out . unshift ( buf . subarray ( start ) . toString ( "utf-8" ) )
}
break
}
out . unshift ( lines [ i ] )
bytes += size
}
return {
text : out.join ( "\n" ) ,
cut : true ,
}
2026-03-30 03:10:01 +00:00
}
2026-05-02 23:18:48 +00:00
const parse = Effect . fn ( "ShellTool.parse" ) ( function * ( command : string , ps : boolean ) {
2026-04-10 17:26:31 +00:00
const tree = yield * Effect . promise ( ( ) = > parser ( ) . then ( ( p ) = > ( ps ? p.ps : p.bash ) . parse ( command ) ) )
2026-03-30 03:10:01 +00:00
if ( ! tree ) throw new Error ( "Failed to parse command" )
2026-04-28 23:10:48 +00:00
return tree
2026-04-10 17:26:31 +00:00
} )
2026-03-30 03:10:01 +00:00
2026-05-02 23:18:48 +00:00
const ask = Effect . fn ( "ShellTool.ask" ) ( function * ( ctx : Tool.Context , scan : Scan ) {
2026-03-30 03:10:01 +00:00
if ( scan . dirs . size > 0 ) {
const globs = Array . from ( scan . dirs ) . map ( ( dir ) = > {
2026-04-10 17:26:31 +00:00
if ( process . platform === "win32" ) return AppFileSystem . normalizePathPattern ( path . join ( dir , "*" ) )
2026-03-30 03:10:01 +00:00
return path . join ( dir , "*" )
} )
2026-04-11 02:36:02 +00:00
yield * ctx . ask ( {
permission : "external_directory" ,
patterns : globs ,
always : globs ,
metadata : { } ,
} )
2026-03-30 03:10:01 +00:00
}
if ( scan . patterns . size === 0 ) return
2026-04-11 02:36:02 +00:00
yield * ctx . ask ( {
2026-05-02 23:18:48 +00:00
permission : ShellID.ToolID ,
2026-04-11 02:36:02 +00:00
patterns : Array.from ( scan . patterns ) ,
always : Array.from ( scan . always ) ,
metadata : { } ,
} )
2026-04-10 17:26:31 +00:00
} )
2026-03-30 03:10:01 +00:00
2026-04-27 00:54:55 +00:00
function cmd ( shell : string , command : string , cwd : string , env : NodeJS.ProcessEnv ) {
if ( process . platform === "win32" && Shell . ps ( shell ) ) {
2026-04-01 23:48:47 +00:00
return ChildProcess . make ( shell , [ "-NoLogo" , "-NoProfile" , "-NonInteractive" , "-Command" , command ] , {
2026-03-30 03:10:01 +00:00
cwd ,
env ,
2026-04-01 23:48:47 +00:00
stdin : "ignore" ,
2026-03-30 03:10:01 +00:00
detached : false ,
} )
}
2026-04-01 23:48:47 +00:00
return ChildProcess . make ( command , [ ] , {
2026-03-30 03:10:01 +00:00
shell ,
cwd ,
env ,
2026-04-01 23:48:47 +00:00
stdin : "ignore" ,
2026-03-30 03:10:01 +00:00
detached : process.platform !== "win32" ,
} )
}
2025-10-07 03:24:07 +00:00
const parser = lazy ( async ( ) = > {
2025-10-31 19:07:36 +00:00
const { Parser } = await import ( "web-tree-sitter" )
const { default : treeWasm } = await import ( "web-tree-sitter/tree-sitter.wasm" as string , {
with : { type : "wasm" } ,
} )
2025-11-18 06:46:49 +00:00
const treePath = resolveWasm ( treeWasm )
2025-10-31 19:07:36 +00:00
await Parser . init ( {
locateFile() {
2025-11-18 06:46:49 +00:00
return treePath
2025-10-31 19:07:36 +00:00
} ,
} )
const { default : bashWasm } = await import ( "tree-sitter-bash/tree-sitter-bash.wasm" as string , {
with : { type : "wasm" } ,
} )
2026-03-30 03:10:01 +00:00
const { default : psWasm } = await import ( "tree-sitter-powershell/tree-sitter-powershell.wasm" as string , {
with : { type : "wasm" } ,
} )
2025-11-18 06:46:49 +00:00
const bashPath = resolveWasm ( bashWasm )
2026-03-30 03:10:01 +00:00
const psPath = resolveWasm ( psWasm )
const [ bashLanguage , psLanguage ] = await Promise . all ( [ Language . load ( bashPath ) , Language . load ( psPath ) ] )
const bash = new Parser ( )
bash . setLanguage ( bashLanguage )
const ps = new Parser ( )
ps . setLanguage ( psLanguage )
return { bash , ps }
2025-07-31 21:19:56 +00:00
} )
2025-07-31 00:57:52 +00:00
2026-05-02 23:18:48 +00:00
export const ShellTool = Tool . define (
ShellID . ToolID ,
2026-04-10 17:26:31 +00:00
Effect . gen ( function * ( ) {
2026-04-27 00:54:55 +00:00
const config = yield * Config . Service
2026-04-10 17:26:31 +00:00
const spawner = yield * ChildProcessSpawner
const fs = yield * AppFileSystem . Service
2026-04-16 01:09:06 +00:00
const trunc = yield * Truncate . Service
2026-04-10 17:26:31 +00:00
const plugin = yield * Plugin . Service
2026-05-14 21:19:14 +00:00
const flags = yield * RuntimeFlags . Service
2026-05-23 15:43:39 +00:00
const defaultTimeoutMs = flags . bashDefaultTimeoutMs ? ? 2 * 60 * 1000
2026-04-10 17:26:31 +00:00
2026-05-02 23:18:48 +00:00
const cygpath = Effect . fn ( "ShellTool.cygpath" ) ( function * ( shell : string , text : string ) {
2026-04-10 17:27:31 +00:00
const lines = yield * spawner
. lines ( ChildProcess . make ( shell , [ "-lc" , 'cygpath -w -- "$1"' , "_" , text ] ) )
. pipe ( Effect . catch ( ( ) = > Effect . succeed ( [ ] as string [ ] ) ) )
2026-04-10 17:26:31 +00:00
const file = lines [ 0 ] ? . trim ( )
if ( ! file ) return
return AppFileSystem . normalizePath ( file )
} )
2026-05-02 23:18:48 +00:00
const resolvePath = Effect . fn ( "ShellTool.resolvePath" ) ( function * ( text : string , root : string , shell : string ) {
2026-04-10 17:26:31 +00:00
if ( process . platform === "win32" ) {
if ( Shell . posix ( shell ) && text . startsWith ( "/" ) && AppFileSystem . windowsPath ( text ) === text ) {
const file = yield * cygpath ( shell , text )
if ( file ) return file
}
return AppFileSystem . normalizePath ( path . resolve ( root , AppFileSystem . windowsPath ( text ) ) )
}
return path . resolve ( root , text )
} )
2026-05-02 23:18:48 +00:00
const argPath = Effect . fn ( "ShellTool.argPath" ) ( function * ( arg : string , cwd : string , ps : boolean , shell : string ) {
2026-04-10 17:26:31 +00:00
const text = ps ? expand ( arg , cwd , shell ) : home ( unquote ( arg ) )
const file = text && prefix ( text )
if ( ! file || dynamic ( file , ps ) ) return
const next = ps ? provider ( file ) : file
if ( ! next ) return
return yield * resolvePath ( next , cwd , shell )
} )
2026-05-02 23:18:48 +00:00
const collect = Effect . fn ( "ShellTool.collect" ) ( function * (
2026-05-01 01:01:06 +00:00
root : Node ,
cwd : string ,
ps : boolean ,
shell : string ,
instance : InstanceContext ,
) {
2026-04-10 17:26:31 +00:00
const scan : Scan = {
dirs : new Set < string > ( ) ,
patterns : new Set < string > ( ) ,
always : new Set < string > ( ) ,
}
2026-05-02 23:18:48 +00:00
const shellKind = ShellID . toKind ( Shell . name ( shell ) )
2026-04-10 17:26:31 +00:00
for ( const node of commands ( root ) ) {
const command = parts ( node )
const tokens = command . map ( ( item ) = > item . text )
2026-05-02 23:18:48 +00:00
const cmd = ps || shellKind === "cmd" ? tokens [ 0 ] ? . toLowerCase ( ) : tokens [ 0 ]
2026-04-10 17:26:31 +00:00
2026-05-02 23:18:48 +00:00
if ( cmd && ( FILES . has ( cmd ) || ( shellKind === "cmd" && CMD_FILES . has ( cmd ) ) ) ) {
for ( const arg of pathArgs ( command , ps , shellKind === "cmd" ) ) {
2026-04-10 17:26:31 +00:00
const resolved = yield * argPath ( arg , cwd , ps , shell )
log . info ( "resolved path" , { arg , resolved } )
2026-05-02 04:02:52 +00:00
if ( ! resolved || containsPath ( resolved , instance ) ) continue
2026-04-10 17:26:31 +00:00
const dir = ( yield * fs . isDir ( resolved ) ) ? resolved : path.dirname ( resolved )
scan . dirs . add ( dir )
}
}
if ( tokens . length && ( ! cmd || ! CWD . has ( cmd ) ) ) {
scan . patterns . add ( source ( node ) )
scan . always . add ( BashArity . prefix ( tokens ) . join ( " " ) + " *" )
}
}
return scan
} )
2026-05-02 23:18:48 +00:00
const shellEnv = Effect . fn ( "ShellTool.shellEnv" ) ( function * ( ctx : Tool.Context , cwd : string ) {
2026-04-10 17:27:31 +00:00
const extra = yield * plugin . trigger (
"shell.env" ,
{ cwd , sessionID : ctx.sessionID , callID : ctx.callID } ,
{ env : { } } ,
)
2026-04-10 17:26:31 +00:00
return {
. . . process . env ,
. . . extra . env ,
2025-11-21 22:29:47 +00:00
}
2026-04-10 17:26:31 +00:00
} )
2026-05-02 23:18:48 +00:00
const run = Effect . fn ( "ShellTool.run" ) ( function * (
2026-04-10 17:26:31 +00:00
input : {
shell : string
command : string
cwd : string
env : NodeJS.ProcessEnv
timeout : number
description : string
} ,
ctx : Tool.Context ,
) {
2026-04-23 21:43:33 +00:00
const limits = yield * trunc . limits ( )
const keep = limits . maxBytes * 2
2026-04-16 01:09:06 +00:00
let full = ""
let last = ""
const list : Chunk [ ] = [ ]
let used = 0
let file = ""
let sink : ReturnType < typeof createWriteStream > | undefined
let cut = false
2026-04-10 17:26:31 +00:00
let expired = false
let aborted = false
2026-05-14 11:04:42 +00:00
const closeSink = Effect . fnUntraced ( function * ( ) {
const stream = sink
if ( ! stream ) return
sink = undefined
if ( stream . destroyed || stream . closed ) return
yield * Effect . promise (
( ) = >
new Promise < void > ( ( resolve ) = > {
let settled = false
const done = ( ) = > {
if ( settled ) return
settled = true
stream . off ( "close" , done )
stream . off ( "error" , done )
stream . off ( "finish" , done )
resolve ( )
}
stream . once ( "close" , done )
stream . once ( "error" , done )
stream . once ( "finish" , done )
stream . end ( done )
} ) ,
) . pipe ( Effect . catch ( ( ) = > Effect . void ) )
} )
2026-04-11 03:12:04 +00:00
yield * ctx . metadata ( {
2026-04-10 17:26:31 +00:00
metadata : {
output : "" ,
description : input.description ,
2025-08-11 05:23:00 +00:00
} ,
2026-04-10 17:26:31 +00:00
} )
const code : number | null = yield * Effect . scoped (
Effect . gen ( function * ( ) {
2026-05-14 11:04:42 +00:00
yield * Effect . addFinalizer ( closeSink )
2026-04-27 00:54:55 +00:00
const handle = yield * spawner . spawn ( cmd ( input . shell , input . command , input . cwd , input . env ) )
2026-04-10 17:26:31 +00:00
yield * Effect . forkScoped (
2026-04-11 03:12:04 +00:00
Stream . runForEach ( Stream . decodeText ( handle . all ) , ( chunk ) = > {
2026-04-16 01:09:06 +00:00
const size = Buffer . byteLength ( chunk , "utf-8" )
list . push ( { text : chunk , size } )
used += size
while ( used > keep && list . length > 1 ) {
const item = list . shift ( )
if ( ! item ) break
used -= item . size
cut = true
}
last = preview ( last + chunk )
if ( file ) {
sink ? . write ( chunk )
} else {
full += chunk
2026-04-23 21:43:33 +00:00
if ( Buffer . byteLength ( full , "utf-8" ) > limits . maxBytes ) {
2026-04-16 01:09:06 +00:00
return trunc . write ( full ) . pipe (
Effect . andThen ( ( next ) = >
Effect . sync ( ( ) = > {
file = next
cut = true
sink = createWriteStream ( next , { flags : "a" } )
full = ""
} ) ,
) ,
Effect . andThen (
ctx . metadata ( {
metadata : {
output : last ,
description : input.description ,
} ,
} ) ,
) ,
)
}
}
2026-04-11 03:12:04 +00:00
return ctx . metadata ( {
metadata : {
2026-04-16 01:09:06 +00:00
output : last ,
2026-04-11 03:12:04 +00:00
description : input.description ,
} ,
} )
2026-04-11 03:13:03 +00:00
} ) ,
2026-04-10 17:26:31 +00:00
)
const abort = Effect . callback < void > ( ( resume ) = > {
if ( ctx . abort . aborted ) return resume ( Effect . void )
const handler = ( ) = > resume ( Effect . void )
ctx . abort . addEventListener ( "abort" , handler , { once : true } )
return Effect . sync ( ( ) = > ctx . abort . removeEventListener ( "abort" , handler ) )
} )
const timeout = Effect . sleep ( ` ${ input . timeout + 100 } millis ` )
const exit = yield * Effect . raceAll ( [
handle . exitCode . pipe ( Effect . map ( ( code ) = > ( { kind : "exit" as const , code } ) ) ) ,
abort . pipe ( Effect . map ( ( ) = > ( { kind : "abort" as const , code : null } ) ) ) ,
timeout . pipe ( Effect . map ( ( ) = > ( { kind : "timeout" as const , code : null } ) ) ) ,
] )
if ( exit . kind === "abort" ) {
aborted = true
yield * handle . kill ( { forceKillAfter : "3 seconds" } ) . pipe ( Effect . orDie )
}
if ( exit . kind === "timeout" ) {
expired = true
yield * handle . kill ( { forceKillAfter : "3 seconds" } ) . pipe ( Effect . orDie )
}
return exit . kind === "exit" ? exit.code : null
} ) ,
) . pipe ( Effect . orDie )
const meta : string [ ] = [ ]
2026-04-14 16:55:03 +00:00
if ( expired ) {
meta . push (
2026-05-02 23:18:48 +00:00
` shell tool terminated command after exceeding timeout ${ input . timeout } ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds. ` ,
2026-04-14 16:55:03 +00:00
)
}
2026-04-10 17:26:31 +00:00
if ( aborted ) meta . push ( "User aborted the command" )
2026-04-16 01:09:06 +00:00
const raw = list . map ( ( item ) = > item . text ) . join ( "" )
2026-04-23 21:43:33 +00:00
const end = tail ( raw , limits . maxLines , limits . maxBytes )
2026-04-16 01:09:06 +00:00
if ( end . cut ) cut = true
if ( ! file && end . cut ) {
file = yield * trunc . write ( raw )
}
let output = end . text
if ( ! output ) output = "(no output)"
if ( cut && file ) {
output = ` ...output truncated... \ n \ nFull output saved to: ${ file } \ n \ n ` + output
}
2026-04-10 17:26:31 +00:00
if ( meta . length > 0 ) {
2026-05-02 23:18:48 +00:00
output += "\n\n<shell_metadata>\n" + meta . join ( "\n" ) + "\n</shell_metadata>"
2026-04-10 17:26:31 +00:00
}
return {
title : input.description ,
metadata : {
2026-04-16 01:09:06 +00:00
output : last || preview ( output ) ,
2026-04-10 17:26:31 +00:00
exit : code ,
description : input.description ,
2026-04-16 01:09:06 +00:00
truncated : cut ,
. . . ( cut && file ? { outputPath : file } : { } ) ,
2026-04-10 17:26:31 +00:00
} ,
output ,
}
} )
2026-04-11 16:33:17 +00:00
return ( ) = >
2026-04-23 21:43:33 +00:00
Effect . gen ( function * ( ) {
2026-04-27 00:54:55 +00:00
const cfg = yield * config . get ( )
const shell = Shell . acceptable ( cfg . shell )
2026-04-11 16:33:17 +00:00
const name = Shell . name ( shell )
2026-04-23 21:43:33 +00:00
const limits = yield * trunc . limits ( )
2026-05-23 15:43:39 +00:00
const prompt = ShellPrompt . render ( name , process . platform , limits , defaultTimeoutMs )
2026-05-02 23:18:48 +00:00
log . info ( "shell tool using shell" , { shell } )
2026-04-23 21:43:33 +00:00
2026-04-11 16:33:17 +00:00
return {
2026-05-02 23:18:48 +00:00
description : prompt.description ,
parameters : prompt.parameters ,
execute : ( params : Parameters , ctx : Tool.Context ) = >
2026-04-11 16:33:17 +00:00
Effect . gen ( function * ( ) {
2026-05-15 17:35:44 +00:00
const instanceCtx = yield * InstanceState . context
2026-04-11 16:33:17 +00:00
const cwd = params . workdir
2026-05-15 17:35:44 +00:00
? yield * resolvePath ( params . workdir , instanceCtx . directory , shell )
: instanceCtx . directory
2026-04-11 16:33:17 +00:00
if ( params . timeout !== undefined && params . timeout < 0 ) {
throw new Error ( ` Invalid timeout value: ${ params . timeout } . Timeout must be a positive number. ` )
}
2026-05-23 15:43:39 +00:00
const timeout = params . timeout ? ? defaultTimeoutMs
2026-04-27 00:54:55 +00:00
const ps = Shell . ps ( shell )
2026-04-28 23:10:48 +00:00
yield * Effect . scoped (
Effect . gen ( function * ( ) {
2026-04-28 23:11:59 +00:00
const tree = yield * Effect . acquireRelease ( parse ( params . command , ps ) , ( tree ) = >
Effect . sync ( ( ) = > tree . delete ( ) ) ,
2026-04-28 23:10:48 +00:00
)
2026-05-15 17:35:44 +00:00
const scan = yield * collect ( tree . rootNode , cwd , ps , shell , instanceCtx )
if ( ! containsPath ( cwd , instanceCtx ) ) scan . dirs . add ( cwd )
2026-04-28 23:10:48 +00:00
yield * ask ( ctx , scan )
} ) ,
)
2026-04-11 16:33:17 +00:00
return yield * run (
{
shell ,
command : params.command ,
cwd ,
env : yield * shellEnv ( ctx , cwd ) ,
timeout ,
description : params.description ,
} ,
ctx ,
)
} ) ,
}
} )
2026-04-10 17:26:31 +00:00
} ) ,
)