on main help sign in
Merge pull request #42 from charmbracelet/ref

fix: improvements around running processes

commitd656802d3e066ce0f323d2714dfbb8ca6d75f614 authorRaphael Amorim <rapha850@gmail.com> dateFri 20 Jun 2025, 16:58 (1 year ago) parentf9890c2d475c22
6 files changed+109−109
go.mod+8 go.sum+21 internal/fsext/fileutil.go+17−7 internal/llm/tools/glob.go+1−1 internal/llm/tools/grep.go+3−11 internal/llm/tools/shell/shell.go+59−90
go.mod+8
@@ -32,6 +32,7 @@ require (
3232 github.com/pressly/goose/v3 v3.24.2
3333 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
3434 github.com/sahilm/fuzzy v0.1.1
35+ github.com/shirou/gopsutil/v4 v4.25.5
3536 github.com/spf13/cobra v1.9.1
3637 github.com/spf13/viper v1.20.0
3738 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
@@ -74,9 +75,11 @@ require (
7475 github.com/disintegration/gift v1.1.2 // indirect
7576 github.com/dlclark/regexp2 v1.11.4 // indirect
7677 github.com/dustin/go-humanize v1.0.1 // indirect
78+ github.com/ebitengine/purego v0.8.4 // indirect
7779 github.com/felixge/httpsnoop v1.0.4 // indirect
7880 github.com/go-logr/logr v1.4.2 // indirect
7981 github.com/go-logr/stdr v1.2.2 // indirect
82+ github.com/go-ole/go-ole v1.2.6 // indirect
8083 github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
8184 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
8285 github.com/google/go-cmp v0.7.0 // indirect
@@ -88,6 +91,7 @@ require (
8891 github.com/inconshreveable/mousetrap v1.1.0 // indirect
8992 github.com/kylelemons/godebug v1.1.0 // indirect
9093 github.com/lucasb-eyer/go-colorful v1.2.0
94+ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
9195 github.com/mattn/go-isatty v0.0.20 // indirect
9296 github.com/mattn/go-runewidth v0.0.16 // indirect
9397 github.com/mfridman/interpolate v0.0.2 // indirect
@@ -101,6 +105,7 @@ require (
101105 github.com/pelletier/go-toml/v2 v2.2.3 // indirect
102106 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
103107 github.com/pmezard/go-difflib v1.0.0 // indirect
108+ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
104109 github.com/rivo/uniseg v0.4.7
105110 github.com/rogpeppe/go-internal v1.14.1 // indirect
106111 github.com/sagikazarmark/locafero v0.7.0 // indirect
@@ -116,10 +121,13 @@ require (
116121 github.com/tidwall/match v1.1.1 // indirect
117122 github.com/tidwall/pretty v1.2.1 // indirect
118123 github.com/tidwall/sjson v1.2.5 // indirect
124+ github.com/tklauser/go-sysconf v0.3.12 // indirect
125+ github.com/tklauser/numcpus v0.6.1 // indirect
119126 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
120127 github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
121128 github.com/yuin/goldmark v1.7.8 // indirect
122129 github.com/yuin/goldmark-emoji v1.0.5 // indirect
130+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
123131 go.opentelemetry.io/auto/sdk v1.1.0 // indirect
124132 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
125133 go.opentelemetry.io/otel v1.35.0 // indirect
go.sum+21
@@ -108,6 +108,8 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA
108108github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
109109github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
110110github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
111+github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
112+github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
111113github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
112114github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
113115github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -121,12 +123,15 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
121123github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
122124github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
123125github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
126+github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
127+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
124128github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
125129github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
126130github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
127131github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
128132github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
129133github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
134+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
130135github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
131136github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
132137github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
@@ -156,6 +161,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
156161github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
157162github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
158163github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
164+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
165+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
159166github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930=
160167github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
161168github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -195,6 +202,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjL
195202github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
196203github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
197204github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
205+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
206+github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
198207github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
199208github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
200209github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -219,6 +228,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN
219228github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
220229github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
221230github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
231+github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
232+github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
222233github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
223234github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
224235github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
@@ -255,6 +266,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
255266github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
256267github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
257268github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
269+github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
270+github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
271+github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
272+github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
258273github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
259274github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
260275github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
@@ -265,6 +280,8 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
265280github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
266281github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
267282github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
283+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
284+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
268285go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
269286go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
270287go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
@@ -311,7 +328,9 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
311328golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
312329golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
313330golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
331+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
314332golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
333+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
315334golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
316335golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
317336golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -320,6 +339,7 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
320339golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
321340golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
322341golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
342+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
323343golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
324344golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
325345golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -347,6 +367,7 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
347367golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
348368golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
349369golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
370+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
350371google.golang.org/genai v1.3.0 h1:tXhPJF30skOjnnDY7ZnjK3q7IKy4PuAlEA0fk7uEaEI=
351372google.golang.org/genai v1.3.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY=
352373google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
internal/fsext/fileutil.go+17−7
@@ -25,12 +25,10 @@ func init() {
2525 rgPath, err = exec.LookPath("rg")
2626 if err != nil {
2727 logging.Warn("Ripgrep (rg) not found in $PATH. Some features might be limited or slower.")
28− rgPath = ""
2928 }
3029 fzfPath, err = exec.LookPath("fzf")
3130 if err != nil {
3231 logging.Warn("FZF not found in $PATH. Some features might be limited or slower.")
33− fzfPath = ""
3432 }
3533}
3634
@@ -49,9 +47,21 @@ func GetRgCmd(globPattern string) *exec.Cmd {
4947 }
5048 rgArgs = append(rgArgs, "--glob", globPattern)
5149 }
52− cmd := exec.Command(rgPath, rgArgs...)
53− cmd.Dir = "."
54− return cmd
50+ return exec.Command(rgPath, rgArgs...)
51+}
52+
53+func GetRgSearchCmd(pattern, path, include string) *exec.Cmd {
54+ if rgPath == "" {
55+ return nil
56+ }
57+ // Use -n to show line numbers and include the matched line
58+ args := []string{"-n", pattern}
59+ if include != "" {
60+ args = append(args, "--glob", include)
61+ }
62+ args = append(args, path)
63+
64+ return exec.Command("rg", args...)
5565}
5666
5767type FileInfo struct {
@@ -89,8 +99,8 @@ func SkipHidden(path string) bool {
8999 "jspm_packages": true,
90100 }
91101
92− parts := strings.Split(path, string(os.PathSeparator))
93− for _, part := range parts {
102+ parts := strings.SplitSeq(path, string(os.PathSeparator))
103+ for part := range parts {
94104 if commonIgnoredDirs[part] {
95105 return true
96106 }
internal/llm/tools/glob.go+1−1
@@ -150,7 +150,7 @@ func runRipgrep(cmd *exec.Cmd, searchRoot string, limit int) ([]string, error) {
150150 }
151151
152152 var matches []string
153− for _, p := range bytes.Split(out, []byte{0}) {
153+ for p := range bytes.SplitSeq(out, []byte{0}) {
154154 if len(p) == 0 {
155155 continue
156156 }
internal/llm/tools/grep.go+3−11
@@ -257,19 +257,11 @@ func searchFiles(pattern, rootPath, include string, limit int) ([]grepMatch, boo
257257}
258258
259259func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
260− _, err := exec.LookPath("rg")
261− if err != nil {
262− return nil, fmt.Errorf("ripgrep not found: %w", err)
263− }
264−
265− // Use -n to show line numbers and include the matched line
266− args := []string{"-n", pattern}
267− if include != "" {
268− args = append(args, "--glob", include)
260+ cmd := fsext.GetRgSearchCmd(pattern, path, include)
261+ if cmd == nil {
262+ return nil, fmt.Errorf("ripgrep not found in $PATH")
269263 }
270− args = append(args, path)
271264
272− cmd := exec.Command("rg", args...)
273265 output, err := cmd.Output()
274266 if err != nil {
275267 if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
internal/llm/tools/shell/shell.go+59−90
@@ -1,9 +1,11 @@
11package shell
22
33import (
4+ "cmp"
45 "context"
56 "errors"
67 "fmt"
8+ "io"
79 "os"
810 "os/exec"
911 "path/filepath"
@@ -13,11 +15,13 @@ import (
1315 "time"
1416
1517 "github.com/charmbracelet/crush/internal/config"
18+ "github.com/charmbracelet/crush/internal/logging"
19+ "github.com/shirou/gopsutil/v4/process"
1620)
1721
1822type PersistentShell struct {
1923 cmd *exec.Cmd
20− stdin *os.File
24+ stdin io.WriteCloser
2125 isAlive bool
2226 cwd string
2327 mu sync.Mutex
@@ -39,22 +43,15 @@ type commandResult struct {
3943 err error
4044}
4145
42−var (
43− shellInstance *PersistentShell
44− shellInstanceOnce sync.Once
45−)
46+var shellInstance *PersistentShell
4647
4748func GetPersistentShell(workingDir string) *PersistentShell {
48− shellInstanceOnce.Do(func() {
49− shellInstance = newPersistentShell(workingDir)
50− })
51−
5249 if shellInstance == nil {
5350 shellInstance = newPersistentShell(workingDir)
54− } else if !shellInstance.isAlive {
51+ }
52+ if !shellInstance.isAlive {
5553 shellInstance = newPersistentShell(shellInstance.cwd)
5654 }
57−
5855 return shellInstance
5956}
6057
@@ -71,16 +68,15 @@ func newPersistentShell(cwd string) *PersistentShell {
7168 shellArgs = cfg.Shell.Args
7269 }
7370
74− if shellPath == "" {
75− shellPath = os.Getenv("SHELL")
76− if shellPath == "" {
77− shellPath = "/bin/bash"
78− }
71+ shellPath = cmp.Or(shellPath, os.Getenv("SHELL"), "/bin/bash")
72+ if !strings.HasSuffix(shellPath, "bash") && !strings.HasSuffix(shellPath, "zsh") {
73+ logging.Warn("only bash and zsh are supported at this time", "shell", shellPath)
74+ shellPath = "/bin/bash"
7975 }
8076
8177 // Default shell args
8278 if len(shellArgs) == 0 {
83− shellArgs = []string{"-l"}
79+ shellArgs = []string{"--login"}
8480 }
8581
8682 cmd := exec.Command(shellPath, shellArgs...)
@@ -100,7 +96,7 @@ func newPersistentShell(cwd string) *PersistentShell {
10096
10197 shell := &PersistentShell{
10298 cmd: cmd,
103− stdin: stdinPipe.(*os.File),
99+ stdin: stdinPipe,
104100 isAlive: true,
105101 cwd: cwd,
106102 commandQueue: make(chan *commandExecution, 10),
@@ -131,12 +127,15 @@ func newPersistentShell(cwd string) *PersistentShell {
131127
132128func (s *PersistentShell) processCommands() {
133129 for cmd := range s.commandQueue {
134− result := s.execCommand(cmd.command, cmd.timeout, cmd.ctx)
135− cmd.resultChan <- result
130+ cmd.resultChan <- s.execCommand(cmd.ctx, cmd.command, cmd.timeout)
136131 }
137132}
138133
139−func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx context.Context) commandResult {
134+const runBashCommandFormat = `%s </dev/null >%q 2>%q
135+echo $? >%q
136+pwd >%q`
137+
138+func (s *PersistentShell) execCommand(ctx context.Context, command string, timeout time.Duration) commandResult {
140139 s.mu.Lock()
141140 defer s.mu.Unlock()
142141
@@ -148,34 +147,22 @@ func (s *PersistentShell) execCommand(command string, timeout time.Duration, ctx
148147 }
149148 }
150149
151− tempDir := os.TempDir()
152− stdoutFile := filepath.Join(tempDir, fmt.Sprintf("crush-stdout-%d", time.Now().UnixNano()))
153− stderrFile := filepath.Join(tempDir, fmt.Sprintf("crush-stderr-%d", time.Now().UnixNano()))
154− statusFile := filepath.Join(tempDir, fmt.Sprintf("crush-status-%d", time.Now().UnixNano()))
155− cwdFile := filepath.Join(tempDir, fmt.Sprintf("crush-cwd-%d", time.Now().UnixNano()))
150+ tmp := os.TempDir()
151+ now := time.Now().UnixNano()
152+ stdoutFile := filepath.Join(tmp, fmt.Sprintf("crush-stdout-%d", now))
153+ stderrFile := filepath.Join(tmp, fmt.Sprintf("crush-stderr-%d", now))
154+ statusFile := filepath.Join(tmp, fmt.Sprintf("crush-status-%d", now))
155+ cwdFile := filepath.Join(tmp, fmt.Sprintf("crush-cwd-%d", now))
156156
157157 defer func() {
158− os.Remove(stdoutFile)
159− os.Remove(stderrFile)
160− os.Remove(statusFile)
161− os.Remove(cwdFile)
158+ _ = os.Remove(stdoutFile)
159+ _ = os.Remove(stderrFile)
160+ _ = os.Remove(statusFile)
161+ _ = os.Remove(cwdFile)
162162 }()
163163
164− fullCommand := fmt.Sprintf(`
165−eval %s < /dev/null > %s 2> %s
166−EXEC_EXIT_CODE=$?
167−pwd > %s
168−echo $EXEC_EXIT_CODE > %s
169−`,
170− shellQuote(command),
171− shellQuote(stdoutFile),
172− shellQuote(stderrFile),
173− shellQuote(cwdFile),
174− shellQuote(statusFile),
175− )
176−
177− _, err := s.stdin.Write([]byte(fullCommand + "\n"))
178− if err != nil {
164+ script := fmt.Sprintf(runBashCommandFormat, command, stdoutFile, stderrFile, statusFile, cwdFile)
165+ if _, err := s.stdin.Write([]byte(script + "\n")); err != nil {
179166 return commandResult{
180167 stderr: fmt.Sprintf("Failed to write command to shell: %v", err),
181168 exitCode: 1,
@@ -184,18 +171,18 @@ echo $EXEC_EXIT_CODE > %s
184171 }
185172
186173 interrupted := false
187−
188− startTime := time.Now()
189−
190174 done := make(chan bool)
191175 go func() {
192176 // Use exponential backoff polling
193− pollInterval := 1 * time.Millisecond
194− maxPollInterval := 100 * time.Millisecond
177+ pollInterval := 10 * time.Millisecond
178+ maxPollInterval := time.Second
195179
196180 ticker := time.NewTicker(pollInterval)
197181 defer ticker.Stop()
198182
183+ timeoutTicker := time.NewTicker(cmp.Or(timeout, time.Hour*99999))
184+ defer timeoutTicker.Stop()
185+
199186 for {
200187 select {
201188 case <-ctx.Done():
@@ -204,28 +191,21 @@ echo $EXEC_EXIT_CODE > %s
204191 done <- true
205192 return
206193
194+ case <-timeoutTicker.C:
195+ s.killChildren()
196+ interrupted = true
197+ done <- true
198+ return
199+
207200 case <-ticker.C:
208− if fileExists(statusFile) && fileSize(statusFile) > 0 {
201+ if fileSize(statusFile) > 0 {
209202 done <- true
210203 return
211204 }
212205
213− if timeout > 0 {
214− elapsed := time.Since(startTime)
215− if elapsed > timeout {
216− s.killChildren()
217− interrupted = true
218− done <- true
219− return
220− }
221− }
222−
223206 // Exponential backoff to reduce CPU usage for longer-running commands
224207 if pollInterval < maxPollInterval {
225− pollInterval = time.Duration(float64(pollInterval) * 1.5)
226− if pollInterval > maxPollInterval {
227− pollInterval = maxPollInterval
228− }
208+ pollInterval = min(time.Duration(float64(pollInterval)*1.5), maxPollInterval)
229209 ticker.Reset(pollInterval)
230210 }
231211 }
@@ -263,23 +243,21 @@ func (s *PersistentShell) killChildren() {
263243 if s.cmd == nil || s.cmd.Process == nil {
264244 return
265245 }
246+ p, err := process.NewProcess(int32(s.cmd.Process.Pid))
247+ if err != nil {
248+ logging.WarnPersist("could not kill persistent shell child processes", "err", err)
249+ return
250+ }
266251
267− pgrepCmd := exec.Command("pgrep", "-P", fmt.Sprintf("%d", s.cmd.Process.Pid))
268− output, err := pgrepCmd.Output()
252+ children, err := p.Children()
269253 if err != nil {
254+ logging.WarnPersist("could not kill persistent shell child processes", "err", err)
270255 return
271256 }
272257
273− for pidStr := range strings.SplitSeq(string(output), "\n") {
274− if pidStr = strings.TrimSpace(pidStr); pidStr != "" {
275− var pid int
276− fmt.Sscanf(pidStr, "%d", &pid)
277− if pid > 0 {
278− proc, err := os.FindProcess(pid)
279− if err == nil {
280− proc.Signal(syscall.SIGTERM)
281− }
282− }
258+ for _, child := range children {
259+ if err := child.SendSignal(syscall.SIGTERM); err != nil {
260+ logging.WarnPersist("could not kill persistent shell child processes", "err", err, "pid", child.Pid)
283261 }
284262 }
285263}
@@ -289,12 +267,10 @@ func (s *PersistentShell) Exec(ctx context.Context, command string, timeoutMs in
289267 return "", "Shell is not alive", 1, false, errors.New("shell is not alive")
290268 }
291269
292− timeout := time.Duration(timeoutMs) * time.Millisecond
293−
294270 resultChan := make(chan commandResult)
295271 s.commandQueue <- &commandExecution{
296272 command: command,
297− timeout: timeout,
273+ timeout: time.Duration(timeoutMs) * time.Millisecond,
298274 resultChan: resultChan,
299275 ctx: ctx,
300276 }
@@ -313,14 +289,12 @@ func (s *PersistentShell) Close() {
313289
314290 s.stdin.Write([]byte("exit\n"))
315291
316− s.cmd.Process.Kill()
292+ if err := s.cmd.Process.Kill(); err != nil {
293+ logging.WarnPersist("could not kill persistent shell", "err", err)
294+ }
317295 s.isAlive = false
318296}
319297
320−func shellQuote(s string) string {
321− return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
322−}
323−
324298func readFileOrEmpty(path string) string {
325299 content, err := os.ReadFile(path)
326300 if err != nil {
@@ -329,11 +303,6 @@ func readFileOrEmpty(path string) string {
329303 return string(content)
330304}
331305
332−func fileExists(path string) bool {
333− _, err := os.Stat(path)
334− return err == nil
335−}
336−
337306func fileSize(path string) int64 {
338307 info, err := os.Stat(path)
339308 if err != nil {