on main help sign in
Merge pull request #2455 from charmbracelet/server-client-2
28f2087Ayman Bagabas
Taskfile.yaml+13
@@ -196,6 +196,19 @@ tasks:
196 - go get charm.land/catwalk@latest
196 - go get charm.land/catwalk@latest
197 - go mod tidy
197 - go mod tidy
198
198
199+ swag:
200+ desc: Generate OpenAPI spec from swag annotations
201+ cmds:
202+ - go run github.com/swaggo/swag/cmd/swag@v1.16.6 init --generalInfo main.go --dir . --output internal/swagger --packageName swagger --parseDependency --parseInternal --parseDepth 5
203+ sources:
204+ - internal/server/*.go
205+ - internal/proto/*.go
206+ - main.go
207+ generates:
208+ - internal/swagger/docs.go
209+ - internal/swagger/swagger.json
210+ - internal/swagger/swagger.yaml
211+
199 sqlc:
212 sqlc:
200 desc: Generate code using SQLC
213 desc: Generate code using SQLC
201 cmds:
214 cmds:
go.mod+14−1
@@ -14,6 +14,7 @@ require (
14 charm.land/x/vcr v0.1.1
14 charm.land/x/vcr v0.1.1
15 github.com/JohannesKaufmann/html-to-markdown v1.6.0
15 github.com/JohannesKaufmann/html-to-markdown v1.6.0
16 github.com/MakeNowJust/heredoc v1.0.0
16 github.com/MakeNowJust/heredoc v1.0.0
17+ github.com/Microsoft/go-winio v0.6.2
17 github.com/PuerkitoBio/goquery v1.12.0
18 github.com/PuerkitoBio/goquery v1.12.0
18 github.com/alecthomas/chroma/v2 v2.23.1
19 github.com/alecthomas/chroma/v2 v2.23.1
19 github.com/atotto/clipboard v0.1.4
20 github.com/atotto/clipboard v0.1.4
@@ -59,12 +60,15 @@ require (
59 github.com/sourcegraph/jsonrpc2 v0.2.1
60 github.com/sourcegraph/jsonrpc2 v0.2.1
60 github.com/spf13/cobra v1.10.2
61 github.com/spf13/cobra v1.10.2
61 github.com/stretchr/testify v1.11.1
62 github.com/stretchr/testify v1.11.1
63+ github.com/swaggo/http-swagger/v2 v2.0.2
64+ github.com/swaggo/swag v1.16.6
62 github.com/tidwall/gjson v1.18.0
65 github.com/tidwall/gjson v1.18.0
63 github.com/tidwall/sjson v1.2.5
66 github.com/tidwall/sjson v1.2.5
64 github.com/zeebo/xxh3 v1.1.0
67 github.com/zeebo/xxh3 v1.1.0
65 go.uber.org/goleak v1.3.0
68 go.uber.org/goleak v1.3.0
66 golang.org/x/net v0.52.0
69 golang.org/x/net v0.52.0
67 golang.org/x/sync v0.20.0
70 golang.org/x/sync v0.20.0
71+ golang.org/x/sys v0.42.0
68 golang.org/x/text v0.35.0
72 golang.org/x/text v0.35.0
69 gopkg.in/natefinch/lumberjack.v2 v2.2.1
73 gopkg.in/natefinch/lumberjack.v2 v2.2.1
70 gopkg.in/yaml.v3 v3.0.1
74 gopkg.in/yaml.v3 v3.0.1
@@ -81,6 +85,7 @@ require (
81 git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
85 git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
82 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
86 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
83 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
87 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
88+ github.com/KyleBanks/depth v1.2.1 // indirect
84 github.com/andybalholm/cascadia v1.3.3 // indirect
89 github.com/andybalholm/cascadia v1.3.3 // indirect
85 github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
90 github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
86 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
91 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
@@ -119,6 +124,10 @@ require (
119 github.com/go-logr/logr v1.4.3 // indirect
124 github.com/go-logr/logr v1.4.3 // indirect
120 github.com/go-logr/stdr v1.2.2 // indirect
125 github.com/go-logr/stdr v1.2.2 // indirect
121 github.com/go-ole/go-ole v1.3.0 // indirect
126 github.com/go-ole/go-ole v1.3.0 // indirect
127+ github.com/go-openapi/jsonpointer v0.19.5 // indirect
128+ github.com/go-openapi/jsonreference v0.20.0 // indirect
129+ github.com/go-openapi/spec v0.20.6 // indirect
130+ github.com/go-openapi/swag v0.19.15 // indirect
122 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
131 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
123 github.com/goccy/go-json v0.10.5 // indirect
132 github.com/goccy/go-json v0.10.5 // indirect
124 github.com/goccy/go-yaml v1.19.2 // indirect
133 github.com/goccy/go-yaml v1.19.2 // indirect
@@ -135,6 +144,7 @@ require (
135 github.com/inconshreveable/mousetrap v1.1.0 // indirect
144 github.com/inconshreveable/mousetrap v1.1.0 // indirect
136 github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
145 github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
137 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
146 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
147+ github.com/josharian/intern v1.0.0 // indirect
138 github.com/kaptinlin/go-i18n v0.2.12 // indirect
148 github.com/kaptinlin/go-i18n v0.2.12 // indirect
139 github.com/kaptinlin/jsonpointer v0.4.17 // indirect
149 github.com/kaptinlin/jsonpointer v0.4.17 // indirect
140 github.com/kaptinlin/jsonschema v0.7.6 // indirect
150 github.com/kaptinlin/jsonschema v0.7.6 // indirect
@@ -166,6 +176,7 @@ require (
166 github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
176 github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
167 github.com/sethvargo/go-retry v0.3.0 // indirect
177 github.com/sethvargo/go-retry v0.3.0 // indirect
168 github.com/spf13/pflag v1.0.9 // indirect
178 github.com/spf13/pflag v1.0.9 // indirect
179+ github.com/swaggo/files/v2 v2.0.0 // indirect
169 github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
180 github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
170 github.com/tidwall/match v1.1.1 // indirect
181 github.com/tidwall/match v1.1.1 // indirect
171 github.com/tidwall/pretty v1.2.1 // indirect
182 github.com/tidwall/pretty v1.2.1 // indirect
@@ -187,10 +198,11 @@ require (
187 golang.org/x/crypto v0.49.0 // indirect
198 golang.org/x/crypto v0.49.0 // indirect
188 golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
199 golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
189 golang.org/x/image v0.38.0 // indirect
200 golang.org/x/image v0.38.0 // indirect
201+ golang.org/x/mod v0.34.0 // indirect
190 golang.org/x/oauth2 v0.36.0 // indirect
202 golang.org/x/oauth2 v0.36.0 // indirect
191− golang.org/x/sys v0.42.0 // indirect
192 golang.org/x/term v0.41.0 // indirect
203 golang.org/x/term v0.41.0 // indirect
193 golang.org/x/time v0.15.0 // indirect
204 golang.org/x/time v0.15.0 // indirect
205+ golang.org/x/tools v0.43.0 // indirect
194 google.golang.org/api v0.271.0 // indirect
206 google.golang.org/api v0.271.0 // indirect
195 google.golang.org/genai v1.51.0 // indirect
207 google.golang.org/genai v1.51.0 // indirect
196 google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
208 google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
@@ -199,6 +211,7 @@ require (
199 gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
211 gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
200 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
212 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
201 gopkg.in/warnings.v0 v0.1.2 // indirect
213 gopkg.in/warnings.v0 v0.1.2 // indirect
214+ gopkg.in/yaml.v2 v2.4.0 // indirect
202 modernc.org/libc v1.70.0 // indirect
215 modernc.org/libc v1.70.0 // indirect
203 modernc.org/mathutil v1.7.1 // indirect
216 modernc.org/mathutil v1.7.1 // indirect
204 modernc.org/memory v1.11.0 // indirect
217 modernc.org/memory v1.11.0 // indirect
go.sum+30
@@ -36,8 +36,12 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mx
36github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
36github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
37github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
37github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
38github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
38github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
39+github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
40+github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
39github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
41github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
40github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
42github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
43+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
44+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
41github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
45github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
42github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
46github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
43github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
47github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
@@ -139,6 +143,7 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ
139github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
143github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
140github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
144github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
141github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
145github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
146+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
142github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
147github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
143github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
148github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
144github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
149github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
@@ -190,6 +195,16 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
190github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
195github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
191github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
196github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
192github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
197github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
198+github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
199+github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
200+github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
201+github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
202+github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
203+github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ=
204+github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
205+github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
206+github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
207+github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
193github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
208github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
194github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
209github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
195github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
210github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
@@ -243,6 +258,7 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
243github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
258github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
244github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4=
259github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4=
245github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw=
260github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw=
261+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
246github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
262github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
247github.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4=
263github.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4=
248github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
264github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
@@ -269,6 +285,9 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
269github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
285github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
270github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
286github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
271github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
287github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
288+github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
289+github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
290+github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
272github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
291github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
273github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
292github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
274github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
293github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -303,6 +322,7 @@ github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt
303github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
322github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
304github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
323github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
305github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
324github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
325+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
306github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
326github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
307github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
327github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
308github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
328github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
@@ -365,11 +385,18 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
365github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
385github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
366github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
386github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
367github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
387github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
388+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
368github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
389github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
369github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
390github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
370github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
391github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
371github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
392github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
372github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
393github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
394+github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
395+github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
396+github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg=
397+github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
398+github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
399+github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
373github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
400github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
374github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
401github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
375github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
402github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -536,7 +563,9 @@ google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH
536google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
563google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
537google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
564google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
538gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
565gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
566+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
539gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
567gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
568+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
540gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
569gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
541gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
570gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
542gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 h1:g3ah7zaWmw41EtOgBNXpx8zk4HYuH3OMwB+qh1Dt834=
571gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 h1:g3ah7zaWmw41EtOgBNXpx8zk4HYuH3OMwB+qh1Dt834=
@@ -551,6 +580,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
551gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
580gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
552gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
581gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
553gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
582gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
583+gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
554gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
584gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
555gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
585gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
556modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
586modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
internal/app/app.go+15−1
@@ -81,7 +81,7 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, er
81 messages := message.NewService(q)
81 messages := message.NewService(q)
82 files := history.NewService(q, conn)
82 files := history.NewService(q, conn)
83 cfg := store.Config()
83 cfg := store.Config()
84− skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
84+ skipPermissionsRequests := store.Overrides().SkipPermissionRequests
85 var allowedTools []string
85 var allowedTools []string
86 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
86 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
87 allowedTools = cfg.Permissions.AllowedTools
87 allowedTools = cfg.Permissions.AllowedTools
@@ -152,6 +152,20 @@ func (app *App) Store() *config.ConfigStore {
152 return app.config
152 return app.config
153}
153}
154
154
155+// Events returns the events channel for the application.
156+func (app *App) Events() <-chan tea.Msg {
157+ return app.events
158+}
159+
160+// SendEvent pushes a message into the application's events channel.
161+// It is non-blocking; the message is dropped if the channel is full.
162+func (app *App) SendEvent(msg tea.Msg) {
163+ select {
164+ case app.events <- msg:
165+ default:
166+ }
167+}
168+
155// AgentNotifications returns the broker for agent notification events.
169// AgentNotifications returns the broker for agent notification events.
156func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] {
170func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] {
157 return app.agentNotifications
171 return app.agentNotifications
internal/backend/agent.gonew file+144
@@ -0,0 +1,144 @@
1+package backend
2+
3+import (
4+ "context"
5+
6+ "github.com/charmbracelet/crush/internal/config"
7+ "github.com/charmbracelet/crush/internal/proto"
8+)
9+
10+// SendMessage sends a prompt to the agent coordinator for the given
11+// workspace and session.
12+func (b *Backend) SendMessage(ctx context.Context, workspaceID string, msg proto.AgentMessage) error {
13+ ws, err := b.GetWorkspace(workspaceID)
14+ if err != nil {
15+ return err
16+ }
17+
18+ if ws.AgentCoordinator == nil {
19+ return ErrAgentNotInitialized
20+ }
21+
22+ _, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt)
23+ return err
24+}
25+
26+// GetAgentInfo returns the agent's model and busy status.
27+func (b *Backend) GetAgentInfo(workspaceID string) (proto.AgentInfo, error) {
28+ ws, err := b.GetWorkspace(workspaceID)
29+ if err != nil {
30+ return proto.AgentInfo{}, err
31+ }
32+
33+ var agentInfo proto.AgentInfo
34+ if ws.AgentCoordinator != nil {
35+ m := ws.AgentCoordinator.Model()
36+ agentInfo = proto.AgentInfo{
37+ Model: m.CatwalkCfg,
38+ ModelCfg: m.ModelCfg,
39+ IsBusy: ws.AgentCoordinator.IsBusy(),
40+ IsReady: true,
41+ }
42+ }
43+ return agentInfo, nil
44+}
45+
46+// InitAgent initializes the coder agent for the workspace.
47+func (b *Backend) InitAgent(ctx context.Context, workspaceID string) error {
48+ ws, err := b.GetWorkspace(workspaceID)
49+ if err != nil {
50+ return err
51+ }
52+
53+ return ws.InitCoderAgent(ctx)
54+}
55+
56+// UpdateAgent reloads the agent model configuration.
57+func (b *Backend) UpdateAgent(ctx context.Context, workspaceID string) error {
58+ ws, err := b.GetWorkspace(workspaceID)
59+ if err != nil {
60+ return err
61+ }
62+
63+ return ws.UpdateAgentModel(ctx)
64+}
65+
66+// CancelSession cancels an ongoing agent operation for the given
67+// session.
68+func (b *Backend) CancelSession(workspaceID, sessionID string) error {
69+ ws, err := b.GetWorkspace(workspaceID)
70+ if err != nil {
71+ return err
72+ }
73+
74+ if ws.AgentCoordinator != nil {
75+ ws.AgentCoordinator.Cancel(sessionID)
76+ }
77+ return nil
78+}
79+
80+// SummarizeSession triggers a session summarization.
81+func (b *Backend) SummarizeSession(ctx context.Context, workspaceID, sessionID string) error {
82+ ws, err := b.GetWorkspace(workspaceID)
83+ if err != nil {
84+ return err
85+ }
86+
87+ if ws.AgentCoordinator == nil {
88+ return ErrAgentNotInitialized
89+ }
90+
91+ return ws.AgentCoordinator.Summarize(ctx, sessionID)
92+}
93+
94+// QueuedPrompts returns the number of queued prompts for the session.
95+func (b *Backend) QueuedPrompts(workspaceID, sessionID string) (int, error) {
96+ ws, err := b.GetWorkspace(workspaceID)
97+ if err != nil {
98+ return 0, err
99+ }
100+
101+ if ws.AgentCoordinator == nil {
102+ return 0, nil
103+ }
104+
105+ return ws.AgentCoordinator.QueuedPrompts(sessionID), nil
106+}
107+
108+// ClearQueue clears the prompt queue for the session.
109+func (b *Backend) ClearQueue(workspaceID, sessionID string) error {
110+ ws, err := b.GetWorkspace(workspaceID)
111+ if err != nil {
112+ return err
113+ }
114+
115+ if ws.AgentCoordinator != nil {
116+ ws.AgentCoordinator.ClearQueue(sessionID)
117+ }
118+ return nil
119+}
120+
121+// QueuedPromptsList returns the list of queued prompt strings for a
122+// session.
123+func (b *Backend) QueuedPromptsList(workspaceID, sessionID string) ([]string, error) {
124+ ws, err := b.GetWorkspace(workspaceID)
125+ if err != nil {
126+ return nil, err
127+ }
128+
129+ if ws.AgentCoordinator == nil {
130+ return nil, nil
131+ }
132+
133+ return ws.AgentCoordinator.QueuedPromptsList(sessionID), nil
134+}
135+
136+// GetDefaultSmallModel returns the default small model for a provider.
137+func (b *Backend) GetDefaultSmallModel(workspaceID, providerID string) (config.SelectedModel, error) {
138+ ws, err := b.GetWorkspace(workspaceID)
139+ if err != nil {
140+ return config.SelectedModel{}, err
141+ }
142+
143+ return ws.GetDefaultSmallModel(providerID), nil
144+}
internal/backend/backend.gonew file+204
@@ -0,0 +1,204 @@
1+// Package backend provides transport-agnostic operations for managing
2+// workspaces, sessions, agents, permissions, and events. It is consumed
3+// by protocol-specific layers such as HTTP (server) and ACP.
4+package backend
5+
6+import (
7+ "context"
8+ "errors"
9+ "fmt"
10+ "log/slog"
11+ "runtime"
12+
13+ "github.com/charmbracelet/crush/internal/app"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/csync"
16+ "github.com/charmbracelet/crush/internal/db"
17+ "github.com/charmbracelet/crush/internal/proto"
18+ "github.com/charmbracelet/crush/internal/ui/util"
19+ "github.com/charmbracelet/crush/internal/version"
20+ "github.com/google/uuid"
21+)
22+
23+// Common errors returned by backend operations.
24+var (
25+ ErrWorkspaceNotFound = errors.New("workspace not found")
26+ ErrLSPClientNotFound = errors.New("LSP client not found")
27+ ErrAgentNotInitialized = errors.New("agent coordinator not initialized")
28+ ErrPathRequired = errors.New("path is required")
29+ ErrInvalidPermissionAction = errors.New("invalid permission action")
30+ ErrUnknownCommand = errors.New("unknown command")
31+)
32+
33+// ShutdownFunc is called when the backend needs to trigger a server
34+// shutdown (e.g. when the last workspace is removed).
35+type ShutdownFunc func()
36+
37+// Backend provides transport-agnostic business logic for the Crush
38+// server. It manages workspaces and delegates to [app.App] services.
39+type Backend struct {
40+ workspaces *csync.Map[string, *Workspace]
41+ cfg *config.ConfigStore
42+ ctx context.Context
43+ shutdownFn ShutdownFunc
44+}
45+
46+// Workspace represents a running [app.App] workspace with its
47+// associated resources and state.
48+type Workspace struct {
49+ *app.App
50+ ID string
51+ Path string
52+ Cfg *config.ConfigStore
53+ Env []string
54+}
55+
56+// New creates a new [Backend].
57+func New(ctx context.Context, cfg *config.ConfigStore, shutdownFn ShutdownFunc) *Backend {
58+ return &Backend{
59+ workspaces: csync.NewMap[string, *Workspace](),
60+ cfg: cfg,
61+ ctx: ctx,
62+ shutdownFn: shutdownFn,
63+ }
64+}
65+
66+// GetWorkspace retrieves a workspace by ID.
67+func (b *Backend) GetWorkspace(id string) (*Workspace, error) {
68+ ws, ok := b.workspaces.Get(id)
69+ if !ok {
70+ return nil, ErrWorkspaceNotFound
71+ }
72+ return ws, nil
73+}
74+
75+// ListWorkspaces returns all running workspaces.
76+func (b *Backend) ListWorkspaces() []proto.Workspace {
77+ workspaces := []proto.Workspace{}
78+ for _, ws := range b.workspaces.Seq2() {
79+ workspaces = append(workspaces, workspaceToProto(ws))
80+ }
81+ return workspaces
82+}
83+
84+// CreateWorkspace initializes a new workspace from the given
85+// parameters. It creates the config, database connection, and
86+// [app.App] instance.
87+func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Workspace, error) {
88+ if args.Path == "" {
89+ return nil, proto.Workspace{}, ErrPathRequired
90+ }
91+
92+ id := uuid.New().String()
93+ cfg, err := config.Init(args.Path, args.DataDir, args.Debug)
94+ if err != nil {
95+ return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
96+ }
97+
98+ cfg.Overrides().SkipPermissionRequests = args.YOLO
99+
100+ if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil {
101+ return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
102+ }
103+
104+ conn, err := db.Connect(b.ctx, cfg.Config().Options.DataDirectory)
105+ if err != nil {
106+ return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err)
107+ }
108+
109+ appWorkspace, err := app.New(b.ctx, conn, cfg)
110+ if err != nil {
111+ return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err)
112+ }
113+
114+ ws := &Workspace{
115+ App: appWorkspace,
116+ ID: id,
117+ Path: args.Path,
118+ Cfg: cfg,
119+ Env: args.Env,
120+ }
121+
122+ b.workspaces.Set(id, ws)
123+
124+ if args.Version != "" && args.Version != version.Version {
125+ slog.Warn("Client/server version mismatch",
126+ "client", args.Version,
127+ "server", version.Version,
128+ )
129+ appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
130+ "Server version %q differs from client version %q. Consider restarting the server.",
131+ version.Version, args.Version,
132+ )))
133+ }
134+
135+ result := proto.Workspace{
136+ ID: id,
137+ Path: args.Path,
138+ DataDir: cfg.Config().Options.DataDirectory,
139+ Debug: cfg.Config().Options.Debug,
140+ YOLO: cfg.Overrides().SkipPermissionRequests,
141+ Config: cfg.Config(),
142+ Env: args.Env,
143+ }
144+
145+ return ws, result, nil
146+}
147+
148+// DeleteWorkspace shuts down and removes a workspace. If it was the
149+// last workspace, the shutdown callback is invoked.
150+func (b *Backend) DeleteWorkspace(id string) {
151+ ws, ok := b.workspaces.Get(id)
152+ if ok {
153+ ws.Shutdown()
154+ }
155+ b.workspaces.Del(id)
156+
157+ if b.workspaces.Len() == 0 && b.shutdownFn != nil {
158+ slog.Info("Last workspace removed, shutting down server...")
159+ b.shutdownFn()
160+ }
161+}
162+
163+// GetWorkspaceProto returns the proto representation of a workspace.
164+func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
165+ ws, err := b.GetWorkspace(id)
166+ if err != nil {
167+ return proto.Workspace{}, err
168+ }
169+ return workspaceToProto(ws), nil
170+}
171+
172+// VersionInfo returns server version information.
173+func (b *Backend) VersionInfo() proto.VersionInfo {
174+ return proto.VersionInfo{
175+ Version: version.Version,
176+ Commit: version.Commit,
177+ GoVersion: runtime.Version(),
178+ Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
179+ }
180+}
181+
182+// Config returns the server-level configuration.
183+func (b *Backend) Config() *config.ConfigStore {
184+ return b.cfg
185+}
186+
187+// Shutdown initiates a graceful server shutdown.
188+func (b *Backend) Shutdown() {
189+ if b.shutdownFn != nil {
190+ b.shutdownFn()
191+ }
192+}
193+
194+func workspaceToProto(ws *Workspace) proto.Workspace {
195+ cfg := ws.Cfg.Config()
196+ return proto.Workspace{
197+ ID: ws.ID,
198+ Path: ws.Path,
199+ YOLO: ws.Cfg.Overrides().SkipPermissionRequests,
200+ DataDir: cfg.Options.DataDirectory,
201+ Debug: cfg.Options.Debug,
202+ Config: cfg,
203+ }
204+}
internal/backend/config.gonew file+214
@@ -0,0 +1,214 @@
1+package backend
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "github.com/charmbracelet/crush/internal/agent"
9+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
10+ "github.com/charmbracelet/crush/internal/commands"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/oauth"
13+)
14+
15+// MCPResourceContents holds the contents of an MCP resource returned
16+// by the backend.
17+type MCPResourceContents struct {
18+ URI string `json:"uri"`
19+ MIMEType string `json:"mime_type,omitempty"`
20+ Text string `json:"text,omitempty"`
21+ Blob []byte `json:"blob,omitempty"`
22+}
23+
24+// SetConfigField sets a key/value pair in the config file for the
25+// given scope.
26+func (b *Backend) SetConfigField(workspaceID string, scope config.Scope, key string, value any) error {
27+ ws, err := b.GetWorkspace(workspaceID)
28+ if err != nil {
29+ return err
30+ }
31+ return ws.Cfg.SetConfigField(scope, key, value)
32+}
33+
34+// RemoveConfigField removes a key from the config file for the given
35+// scope.
36+func (b *Backend) RemoveConfigField(workspaceID string, scope config.Scope, key string) error {
37+ ws, err := b.GetWorkspace(workspaceID)
38+ if err != nil {
39+ return err
40+ }
41+ return ws.Cfg.RemoveConfigField(scope, key)
42+}
43+
44+// UpdatePreferredModel updates the preferred model for the given type
45+// and persists it to the config file at the given scope.
46+func (b *Backend) UpdatePreferredModel(workspaceID string, scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
47+ ws, err := b.GetWorkspace(workspaceID)
48+ if err != nil {
49+ return err
50+ }
51+ return ws.Cfg.UpdatePreferredModel(scope, modelType, model)
52+}
53+
54+// SetCompactMode sets the compact mode setting and persists it.
55+func (b *Backend) SetCompactMode(workspaceID string, scope config.Scope, enabled bool) error {
56+ ws, err := b.GetWorkspace(workspaceID)
57+ if err != nil {
58+ return err
59+ }
60+ return ws.Cfg.SetCompactMode(scope, enabled)
61+}
62+
63+// SetProviderAPIKey sets the API key for a provider and persists it.
64+func (b *Backend) SetProviderAPIKey(workspaceID string, scope config.Scope, providerID string, apiKey any) error {
65+ ws, err := b.GetWorkspace(workspaceID)
66+ if err != nil {
67+ return err
68+ }
69+ return ws.Cfg.SetProviderAPIKey(scope, providerID, apiKey)
70+}
71+
72+// ImportCopilot attempts to import a GitHub Copilot token from disk.
73+func (b *Backend) ImportCopilot(workspaceID string) (*oauth.Token, bool, error) {
74+ ws, err := b.GetWorkspace(workspaceID)
75+ if err != nil {
76+ return nil, false, err
77+ }
78+ token, ok := ws.Cfg.ImportCopilot()
79+ return token, ok, nil
80+}
81+
82+// RefreshOAuthToken refreshes the OAuth token for a provider.
83+func (b *Backend) RefreshOAuthToken(ctx context.Context, workspaceID string, scope config.Scope, providerID string) error {
84+ ws, err := b.GetWorkspace(workspaceID)
85+ if err != nil {
86+ return err
87+ }
88+ return ws.Cfg.RefreshOAuthToken(ctx, scope, providerID)
89+}
90+
91+// ProjectNeedsInitialization checks whether the project in this
92+// workspace needs initialization.
93+func (b *Backend) ProjectNeedsInitialization(workspaceID string) (bool, error) {
94+ ws, err := b.GetWorkspace(workspaceID)
95+ if err != nil {
96+ return false, err
97+ }
98+ return config.ProjectNeedsInitialization(ws.Cfg)
99+}
100+
101+// MarkProjectInitialized marks the project as initialized.
102+func (b *Backend) MarkProjectInitialized(workspaceID string) error {
103+ ws, err := b.GetWorkspace(workspaceID)
104+ if err != nil {
105+ return err
106+ }
107+ return config.MarkProjectInitialized(ws.Cfg)
108+}
109+
110+// InitializePrompt builds the initialization prompt for the workspace.
111+func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
112+ ws, err := b.GetWorkspace(workspaceID)
113+ if err != nil {
114+ return "", err
115+ }
116+ return agent.InitializePrompt(ws.Cfg)
117+}
118+
119+// EnableDockerMCP validates Docker MCP availability, stages the
120+// configuration, starts the MCP client, and persists the config.
121+func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
122+ ws, err := b.GetWorkspace(workspaceID)
123+ if err != nil {
124+ return err
125+ }
126+
127+ mcpConfig, err := ws.Cfg.PrepareDockerMCPConfig()
128+ if err != nil {
129+ return err
130+ }
131+
132+ if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, ws.Cfg); err != nil {
133+ disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
134+ delete(ws.Cfg.Config().MCP, config.DockerMCPName)
135+ return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
136+ }
137+
138+ if err := ws.Cfg.PersistDockerMCPConfig(mcpConfig); err != nil {
139+ disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
140+ delete(ws.Cfg.Config().MCP, config.DockerMCPName)
141+ return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
142+ }
143+
144+ return nil
145+}
146+
147+// DisableDockerMCP closes the Docker MCP client, removes the
148+// configuration, and persists the change.
149+func (b *Backend) DisableDockerMCP(workspaceID string) error {
150+ ws, err := b.GetWorkspace(workspaceID)
151+ if err != nil {
152+ return err
153+ }
154+
155+ if err := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName); err != nil {
156+ return fmt.Errorf("failed to disable docker MCP: %w", err)
157+ }
158+
159+ if err := ws.Cfg.DisableDockerMCP(); err != nil {
160+ return err
161+ }
162+
163+ return nil
164+}
165+
166+// RefreshMCPTools refreshes the tools for a named MCP server.
167+func (b *Backend) RefreshMCPTools(ctx context.Context, workspaceID, name string) error {
168+ ws, err := b.GetWorkspace(workspaceID)
169+ if err != nil {
170+ return err
171+ }
172+ mcptools.RefreshTools(ctx, ws.Cfg, name)
173+ return nil
174+}
175+
176+// ReadMCPResource reads a resource from a named MCP server.
177+func (b *Backend) ReadMCPResource(ctx context.Context, workspaceID, name, uri string) ([]MCPResourceContents, error) {
178+ ws, err := b.GetWorkspace(workspaceID)
179+ if err != nil {
180+ return nil, err
181+ }
182+ contents, err := mcptools.ReadResource(ctx, ws.Cfg, name, uri)
183+ if err != nil {
184+ return nil, err
185+ }
186+ result := make([]MCPResourceContents, len(contents))
187+ for i, c := range contents {
188+ result[i] = MCPResourceContents{
189+ URI: c.URI,
190+ MIMEType: c.MIMEType,
191+ Text: c.Text,
192+ Blob: c.Blob,
193+ }
194+ }
195+ return result, nil
196+}
197+
198+// GetMCPPrompt retrieves a prompt from a named MCP server.
199+func (b *Backend) GetMCPPrompt(workspaceID, clientID, promptID string, args map[string]string) (string, error) {
200+ ws, err := b.GetWorkspace(workspaceID)
201+ if err != nil {
202+ return "", err
203+ }
204+ return commands.GetMCPPrompt(ws.Cfg, clientID, promptID, args)
205+}
206+
207+// GetWorkingDir returns the working directory for a workspace.
208+func (b *Backend) GetWorkingDir(workspaceID string) (string, error) {
209+ ws, err := b.GetWorkspace(workspaceID)
210+ if err != nil {
211+ return "", err
212+ }
213+ return ws.Cfg.WorkingDir(), nil
214+}
internal/backend/events.gonew file+107
@@ -0,0 +1,107 @@
1+package backend
2+
3+import (
4+ "context"
5+
6+ tea "charm.land/bubbletea/v2"
7+
8+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
9+ "github.com/charmbracelet/crush/internal/app"
10+ "github.com/charmbracelet/crush/internal/config"
11+)
12+
13+// SubscribeEvents returns the event channel for a workspace's app.
14+func (b *Backend) SubscribeEvents(workspaceID string) (<-chan tea.Msg, error) {
15+ ws, err := b.GetWorkspace(workspaceID)
16+ if err != nil {
17+ return nil, err
18+ }
19+
20+ return ws.Events(), nil
21+}
22+
23+// GetLSPStates returns the state of all LSP clients.
24+func (b *Backend) GetLSPStates(workspaceID string) (map[string]app.LSPClientInfo, error) {
25+ _, err := b.GetWorkspace(workspaceID)
26+ if err != nil {
27+ return nil, err
28+ }
29+
30+ return app.GetLSPStates(), nil
31+}
32+
33+// GetLSPDiagnostics returns diagnostics for a specific LSP client in
34+// the workspace.
35+func (b *Backend) GetLSPDiagnostics(workspaceID, lspName string) (any, error) {
36+ ws, err := b.GetWorkspace(workspaceID)
37+ if err != nil {
38+ return nil, err
39+ }
40+
41+ for name, client := range ws.LSPManager.Clients().Seq2() {
42+ if name == lspName {
43+ return client.GetDiagnostics(), nil
44+ }
45+ }
46+
47+ return nil, ErrLSPClientNotFound
48+}
49+
50+// GetWorkspaceConfig returns the workspace-level configuration.
51+func (b *Backend) GetWorkspaceConfig(workspaceID string) (*config.Config, error) {
52+ ws, err := b.GetWorkspace(workspaceID)
53+ if err != nil {
54+ return nil, err
55+ }
56+
57+ return ws.Cfg.Config(), nil
58+}
59+
60+// GetWorkspaceProviders returns the configured providers for a
61+// workspace.
62+func (b *Backend) GetWorkspaceProviders(workspaceID string) (any, error) {
63+ ws, err := b.GetWorkspace(workspaceID)
64+ if err != nil {
65+ return nil, err
66+ }
67+
68+ providers, _ := config.Providers(ws.Cfg.Config())
69+ return providers, nil
70+}
71+
72+// LSPStart starts an LSP server for the given path.
73+func (b *Backend) LSPStart(ctx context.Context, workspaceID, path string) error {
74+ ws, err := b.GetWorkspace(workspaceID)
75+ if err != nil {
76+ return err
77+ }
78+
79+ ws.LSPManager.Start(ctx, path)
80+ return nil
81+}
82+
83+// LSPStopAll stops all LSP servers for a workspace.
84+func (b *Backend) LSPStopAll(ctx context.Context, workspaceID string) error {
85+ ws, err := b.GetWorkspace(workspaceID)
86+ if err != nil {
87+ return err
88+ }
89+
90+ ws.LSPManager.StopAll(ctx)
91+ return nil
92+}
93+
94+// MCPGetStates returns the current state of all MCP clients.
95+func (b *Backend) MCPGetStates(_ string) map[string]mcptools.ClientInfo {
96+ return mcptools.GetStates()
97+}
98+
99+// MCPRefreshPrompts refreshes prompts for a named MCP client.
100+func (b *Backend) MCPRefreshPrompts(ctx context.Context, _ string, name string) {
101+ mcptools.RefreshPrompts(ctx, name)
102+}
103+
104+// MCPRefreshResources refreshes resources for a named MCP client.
105+func (b *Backend) MCPRefreshResources(ctx context.Context, _ string, name string) {
106+ mcptools.RefreshResources(ctx, name)
107+}
internal/backend/filetracker.gonew file+37
@@ -0,0 +1,37 @@
1+package backend
2+
3+import (
4+ "context"
5+ "time"
6+)
7+
8+// FileTrackerRecordRead records a file read for a session.
9+func (b *Backend) FileTrackerRecordRead(ctx context.Context, workspaceID, sessionID, path string) error {
10+ ws, err := b.GetWorkspace(workspaceID)
11+ if err != nil {
12+ return err
13+ }
14+
15+ ws.FileTracker.RecordRead(ctx, sessionID, path)
16+ return nil
17+}
18+
19+// FileTrackerLastReadTime returns the last read time for a file in a session.
20+func (b *Backend) FileTrackerLastReadTime(ctx context.Context, workspaceID, sessionID, path string) (time.Time, error) {
21+ ws, err := b.GetWorkspace(workspaceID)
22+ if err != nil {
23+ return time.Time{}, err
24+ }
25+
26+ return ws.FileTracker.LastReadTime(ctx, sessionID, path), nil
27+}
28+
29+// FileTrackerListReadFiles returns the list of read files for a session.
30+func (b *Backend) FileTrackerListReadFiles(ctx context.Context, workspaceID, sessionID string) ([]string, error) {
31+ ws, err := b.GetWorkspace(workspaceID)
32+ if err != nil {
33+ return nil, err
34+ }
35+
36+ return ws.FileTracker.ListReadFiles(ctx, sessionID)
37+}
internal/backend/permission.gonew file+59
@@ -0,0 +1,59 @@
1+package backend
2+
3+import (
4+ "github.com/charmbracelet/crush/internal/permission"
5+ "github.com/charmbracelet/crush/internal/proto"
6+)
7+
8+// GrantPermission grants, denies, or persistently grants a permission
9+// request.
10+func (b *Backend) GrantPermission(workspaceID string, req proto.PermissionGrant) error {
11+ ws, err := b.GetWorkspace(workspaceID)
12+ if err != nil {
13+ return err
14+ }
15+
16+ perm := permission.PermissionRequest{
17+ ID: req.Permission.ID,
18+ SessionID: req.Permission.SessionID,
19+ ToolCallID: req.Permission.ToolCallID,
20+ ToolName: req.Permission.ToolName,
21+ Description: req.Permission.Description,
22+ Action: req.Permission.Action,
23+ Params: req.Permission.Params,
24+ Path: req.Permission.Path,
25+ }
26+
27+ switch req.Action {
28+ case proto.PermissionAllow:
29+ ws.Permissions.Grant(perm)
30+ case proto.PermissionAllowForSession:
31+ ws.Permissions.GrantPersistent(perm)
32+ case proto.PermissionDeny:
33+ ws.Permissions.Deny(perm)
34+ default:
35+ return ErrInvalidPermissionAction
36+ }
37+ return nil
38+}
39+
40+// SetPermissionsSkip sets whether permission prompts are skipped.
41+func (b *Backend) SetPermissionsSkip(workspaceID string, skip bool) error {
42+ ws, err := b.GetWorkspace(workspaceID)
43+ if err != nil {
44+ return err
45+ }
46+
47+ ws.Permissions.SetSkipRequests(skip)
48+ return nil
49+}
50+
51+// GetPermissionsSkip returns whether permission prompts are skipped.
52+func (b *Backend) GetPermissionsSkip(workspaceID string) (bool, error) {
53+ ws, err := b.GetWorkspace(workspaceID)
54+ if err != nil {
55+ return false, err
56+ }
57+
58+ return ws.Permissions.SkipRequests(), nil
59+}
internal/backend/session.gonew file+126
@@ -0,0 +1,126 @@
1+package backend
2+
3+import (
4+ "context"
5+
6+ "github.com/charmbracelet/crush/internal/message"
7+ "github.com/charmbracelet/crush/internal/proto"
8+ "github.com/charmbracelet/crush/internal/session"
9+)
10+
11+// CreateSession creates a new session in the given workspace.
12+func (b *Backend) CreateSession(ctx context.Context, workspaceID, title string) (session.Session, error) {
13+ ws, err := b.GetWorkspace(workspaceID)
14+ if err != nil {
15+ return session.Session{}, err
16+ }
17+
18+ return ws.Sessions.Create(ctx, title)
19+}
20+
21+// GetSession retrieves a session by workspace and session ID.
22+func (b *Backend) GetSession(ctx context.Context, workspaceID, sessionID string) (session.Session, error) {
23+ ws, err := b.GetWorkspace(workspaceID)
24+ if err != nil {
25+ return session.Session{}, err
26+ }
27+
28+ return ws.Sessions.Get(ctx, sessionID)
29+}
30+
31+// ListSessions returns all sessions in the given workspace.
32+func (b *Backend) ListSessions(ctx context.Context, workspaceID string) ([]session.Session, error) {
33+ ws, err := b.GetWorkspace(workspaceID)
34+ if err != nil {
35+ return nil, err
36+ }
37+
38+ return ws.Sessions.List(ctx)
39+}
40+
41+// GetAgentSession returns session metadata with the agent's busy
42+// status.
43+func (b *Backend) GetAgentSession(ctx context.Context, workspaceID, sessionID string) (proto.AgentSession, error) {
44+ ws, err := b.GetWorkspace(workspaceID)
45+ if err != nil {
46+ return proto.AgentSession{}, err
47+ }
48+
49+ se, err := ws.Sessions.Get(ctx, sessionID)
50+ if err != nil {
51+ return proto.AgentSession{}, err
52+ }
53+
54+ var isSessionBusy bool
55+ if ws.AgentCoordinator != nil {
56+ isSessionBusy = ws.AgentCoordinator.IsSessionBusy(sessionID)
57+ }
58+
59+ return proto.AgentSession{
60+ Session: proto.Session{
61+ ID: se.ID,
62+ Title: se.Title,
63+ },
64+ IsBusy: isSessionBusy,
65+ }, nil
66+}
67+
68+// ListSessionMessages returns all messages for a session.
69+func (b *Backend) ListSessionMessages(ctx context.Context, workspaceID, sessionID string) ([]message.Message, error) {
70+ ws, err := b.GetWorkspace(workspaceID)
71+ if err != nil {
72+ return nil, err
73+ }
74+
75+ return ws.Messages.List(ctx, sessionID)
76+}
77+
78+// ListSessionHistory returns the history items for a session.
79+func (b *Backend) ListSessionHistory(ctx context.Context, workspaceID, sessionID string) (any, error) {
80+ ws, err := b.GetWorkspace(workspaceID)
81+ if err != nil {
82+ return nil, err
83+ }
84+
85+ return ws.History.ListBySession(ctx, sessionID)
86+}
87+
88+// SaveSession updates a session in the given workspace.
89+func (b *Backend) SaveSession(ctx context.Context, workspaceID string, sess session.Session) (session.Session, error) {
90+ ws, err := b.GetWorkspace(workspaceID)
91+ if err != nil {
92+ return session.Session{}, err
93+ }
94+
95+ return ws.Sessions.Save(ctx, sess)
96+}
97+
98+// DeleteSession deletes a session from the given workspace.
99+func (b *Backend) DeleteSession(ctx context.Context, workspaceID, sessionID string) error {
100+ ws, err := b.GetWorkspace(workspaceID)
101+ if err != nil {
102+ return err
103+ }
104+
105+ return ws.Sessions.Delete(ctx, sessionID)
106+}
107+
108+// ListUserMessages returns user-role messages for a session.
109+func (b *Backend) ListUserMessages(ctx context.Context, workspaceID, sessionID string) ([]message.Message, error) {
110+ ws, err := b.GetWorkspace(workspaceID)
111+ if err != nil {
112+ return nil, err
113+ }
114+
115+ return ws.Messages.ListUserMessages(ctx, sessionID)
116+}
117+
118+// ListAllUserMessages returns all user-role messages across sessions.
119+func (b *Backend) ListAllUserMessages(ctx context.Context, workspaceID string) ([]message.Message, error) {
120+ ws, err := b.GetWorkspace(workspaceID)
121+ if err != nil {
122+ return nil, err
123+ }
124+
125+ return ws.Messages.ListAllUserMessages(ctx)
126+}
internal/backend/util.gonew file+22
@@ -0,0 +1,22 @@
1+package backend
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+)
8+
9+func createDotCrushDir(dir string) error {
10+ if err := os.MkdirAll(dir, 0o700); err != nil {
11+ return fmt.Errorf("failed to create data directory: %q %w", dir, err)
12+ }
13+
14+ gitIgnorePath := filepath.Join(dir, ".gitignore")
15+ if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
16+ if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
17+ return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
18+ }
19+ }
20+
21+ return nil
22+}
internal/client/client.gonew file+196
@@ -0,0 +1,196 @@
1+package client
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net"
9+ "net/http"
10+ "net/url"
11+ stdpath "path"
12+ "path/filepath"
13+ "time"
14+
15+ "github.com/charmbracelet/crush/internal/config"
16+ "github.com/charmbracelet/crush/internal/proto"
17+ "github.com/charmbracelet/crush/internal/server"
18+)
19+
20+// DummyHost is used to satisfy the http.Client's requirement for a URL.
21+const DummyHost = "api.crush.localhost"
22+
23+// Client represents an RPC client connected to a Crush server.
24+type Client struct {
25+ h *http.Client
26+ path string
27+ network string
28+ addr string
29+}
30+
31+// DefaultClient creates a new [Client] connected to the default server address.
32+func DefaultClient(path string) (*Client, error) {
33+ host, err := server.ParseHostURL(server.DefaultHost())
34+ if err != nil {
35+ return nil, err
36+ }
37+ return NewClient(path, host.Scheme, host.Host)
38+}
39+
40+// NewClient creates a new [Client] connected to the server at the given
41+// network and address.
42+func NewClient(path, network, address string) (*Client, error) {
43+ c := new(Client)
44+ c.path = filepath.Clean(path)
45+ c.network = network
46+ c.addr = address
47+ p := &http.Protocols{}
48+ p.SetHTTP1(true)
49+ p.SetUnencryptedHTTP2(true)
50+ tr := http.DefaultTransport.(*http.Transport).Clone()
51+ tr.Protocols = p
52+ tr.DialContext = c.dialer
53+ if c.network == "npipe" || c.network == "unix" {
54+ tr.DisableCompression = true
55+ }
56+ c.h = &http.Client{
57+ Transport: tr,
58+ Timeout: 0,
59+ }
60+ return c, nil
61+}
62+
63+// Path returns the client's workspace filesystem path.
64+func (c *Client) Path() string {
65+ return c.path
66+}
67+
68+// GetGlobalConfig retrieves the server's configuration.
69+func (c *Client) GetGlobalConfig(ctx context.Context) (*config.Config, error) {
70+ var cfg config.Config
71+ rsp, err := c.get(ctx, "/config", nil, nil)
72+ if err != nil {
73+ return nil, err
74+ }
75+ defer rsp.Body.Close()
76+ if err := json.NewDecoder(rsp.Body).Decode(&cfg); err != nil {
77+ return nil, err
78+ }
79+ return &cfg, nil
80+}
81+
82+// Health checks the server's health status.
83+func (c *Client) Health(ctx context.Context) error {
84+ rsp, err := c.get(ctx, "/health", nil, nil)
85+ if err != nil {
86+ return err
87+ }
88+ defer rsp.Body.Close()
89+ if rsp.StatusCode != http.StatusOK {
90+ return fmt.Errorf("server health check failed: %s", rsp.Status)
91+ }
92+ return nil
93+}
94+
95+// VersionInfo retrieves the server's version information.
96+func (c *Client) VersionInfo(ctx context.Context) (*proto.VersionInfo, error) {
97+ var vi proto.VersionInfo
98+ rsp, err := c.get(ctx, "version", nil, nil)
99+ if err != nil {
100+ return nil, err
101+ }
102+ defer rsp.Body.Close()
103+ if err := json.NewDecoder(rsp.Body).Decode(&vi); err != nil {
104+ return nil, err
105+ }
106+ return &vi, nil
107+}
108+
109+// ShutdownServer sends a shutdown request to the server.
110+func (c *Client) ShutdownServer(ctx context.Context) error {
111+ rsp, err := c.post(ctx, "/control", nil, jsonBody(proto.ServerControl{
112+ Command: "shutdown",
113+ }), nil)
114+ if err != nil {
115+ return err
116+ }
117+ defer rsp.Body.Close()
118+ if rsp.StatusCode != http.StatusOK {
119+ return fmt.Errorf("server shutdown failed: %s", rsp.Status)
120+ }
121+ return nil
122+}
123+
124+func (c *Client) dialer(ctx context.Context, network, address string) (net.Conn, error) {
125+ d := net.Dialer{
126+ Timeout: 30 * time.Second,
127+ KeepAlive: 30 * time.Second,
128+ }
129+ switch c.network {
130+ case "npipe":
131+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
132+ defer cancel()
133+ return dialPipeContext(ctx, c.addr)
134+ case "unix":
135+ return d.DialContext(ctx, "unix", c.addr)
136+ default:
137+ return d.DialContext(ctx, network, address)
138+ }
139+}
140+
141+func (c *Client) get(ctx context.Context, path string, query url.Values, headers http.Header) (*http.Response, error) {
142+ return c.sendReq(ctx, http.MethodGet, path, query, nil, headers)
143+}
144+
145+func (c *Client) post(ctx context.Context, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
146+ return c.sendReq(ctx, http.MethodPost, path, query, body, headers)
147+}
148+
149+func (c *Client) delete(ctx context.Context, path string, query url.Values, headers http.Header) (*http.Response, error) {
150+ return c.sendReq(ctx, http.MethodDelete, path, query, nil, headers)
151+}
152+
153+func (c *Client) put(ctx context.Context, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
154+ return c.sendReq(ctx, http.MethodPut, path, query, body, headers)
155+}
156+
157+func (c *Client) sendReq(ctx context.Context, method, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
158+ url := (&url.URL{
159+ Path: stdpath.Join("/v1", path),
160+ RawQuery: query.Encode(),
161+ }).String()
162+ req, err := c.buildReq(ctx, method, url, body, headers)
163+ if err != nil {
164+ return nil, err
165+ }
166+
167+ rsp, err := c.h.Do(req)
168+ if err != nil {
169+ return nil, err
170+ }
171+
172+ return rsp, nil
173+}
174+
175+func (c *Client) buildReq(ctx context.Context, method, url string, body io.Reader, headers http.Header) (*http.Request, error) {
176+ r, err := http.NewRequestWithContext(ctx, method, url, body)
177+ if err != nil {
178+ return nil, err
179+ }
180+
181+ for k, v := range headers {
182+ r.Header[http.CanonicalHeaderKey(k)] = v
183+ }
184+
185+ r.URL.Scheme = "http"
186+ r.URL.Host = c.addr
187+ if c.network == "npipe" || c.network == "unix" {
188+ r.Host = DummyHost
189+ }
190+
191+ if body != nil && r.Header.Get("Content-Type") == "" {
192+ r.Header.Set("Content-Type", "text/plain")
193+ }
194+
195+ return r, nil
196+}
internal/client/config.gonew file+278
@@ -0,0 +1,278 @@
1+package client
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "net/http"
8+
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/oauth"
11+)
12+
13+// SetConfigField sets a config key/value pair on the server.
14+func (c *Client) SetConfigField(ctx context.Context, id string, scope config.Scope, key string, value any) error {
15+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/set", id), nil, jsonBody(struct {
16+ Scope config.Scope `json:"scope"`
17+ Key string `json:"key"`
18+ Value any `json:"value"`
19+ }{Scope: scope, Key: key, Value: value}), http.Header{"Content-Type": []string{"application/json"}})
20+ if err != nil {
21+ return fmt.Errorf("failed to set config field: %w", err)
22+ }
23+ defer rsp.Body.Close()
24+ if rsp.StatusCode != http.StatusOK {
25+ return fmt.Errorf("failed to set config field: status code %d", rsp.StatusCode)
26+ }
27+ return nil
28+}
29+
30+// RemoveConfigField removes a config key on the server.
31+func (c *Client) RemoveConfigField(ctx context.Context, id string, scope config.Scope, key string) error {
32+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/remove", id), nil, jsonBody(struct {
33+ Scope config.Scope `json:"scope"`
34+ Key string `json:"key"`
35+ }{Scope: scope, Key: key}), http.Header{"Content-Type": []string{"application/json"}})
36+ if err != nil {
37+ return fmt.Errorf("failed to remove config field: %w", err)
38+ }
39+ defer rsp.Body.Close()
40+ if rsp.StatusCode != http.StatusOK {
41+ return fmt.Errorf("failed to remove config field: status code %d", rsp.StatusCode)
42+ }
43+ return nil
44+}
45+
46+// UpdatePreferredModel updates the preferred model on the server.
47+func (c *Client) UpdatePreferredModel(ctx context.Context, id string, scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
48+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/model", id), nil, jsonBody(struct {
49+ Scope config.Scope `json:"scope"`
50+ ModelType config.SelectedModelType `json:"model_type"`
51+ Model config.SelectedModel `json:"model"`
52+ }{Scope: scope, ModelType: modelType, Model: model}), http.Header{"Content-Type": []string{"application/json"}})
53+ if err != nil {
54+ return fmt.Errorf("failed to update preferred model: %w", err)
55+ }
56+ defer rsp.Body.Close()
57+ if rsp.StatusCode != http.StatusOK {
58+ return fmt.Errorf("failed to update preferred model: status code %d", rsp.StatusCode)
59+ }
60+ return nil
61+}
62+
63+// SetCompactMode sets compact mode on the server.
64+func (c *Client) SetCompactMode(ctx context.Context, id string, scope config.Scope, enabled bool) error {
65+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/compact", id), nil, jsonBody(struct {
66+ Scope config.Scope `json:"scope"`
67+ Enabled bool `json:"enabled"`
68+ }{Scope: scope, Enabled: enabled}), http.Header{"Content-Type": []string{"application/json"}})
69+ if err != nil {
70+ return fmt.Errorf("failed to set compact mode: %w", err)
71+ }
72+ defer rsp.Body.Close()
73+ if rsp.StatusCode != http.StatusOK {
74+ return fmt.Errorf("failed to set compact mode: status code %d", rsp.StatusCode)
75+ }
76+ return nil
77+}
78+
79+// SetProviderAPIKey sets a provider API key on the server.
80+func (c *Client) SetProviderAPIKey(ctx context.Context, id string, scope config.Scope, providerID string, apiKey any) error {
81+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/provider-key", id), nil, jsonBody(struct {
82+ Scope config.Scope `json:"scope"`
83+ ProviderID string `json:"provider_id"`
84+ APIKey any `json:"api_key"`
85+ }{Scope: scope, ProviderID: providerID, APIKey: apiKey}), http.Header{"Content-Type": []string{"application/json"}})
86+ if err != nil {
87+ return fmt.Errorf("failed to set provider API key: %w", err)
88+ }
89+ defer rsp.Body.Close()
90+ if rsp.StatusCode != http.StatusOK {
91+ return fmt.Errorf("failed to set provider API key: status code %d", rsp.StatusCode)
92+ }
93+ return nil
94+}
95+
96+// ImportCopilot attempts to import a GitHub Copilot token on the
97+// server.
98+func (c *Client) ImportCopilot(ctx context.Context, id string) (*oauth.Token, bool, error) {
99+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/import-copilot", id), nil, nil, nil)
100+ if err != nil {
101+ return nil, false, fmt.Errorf("failed to import copilot: %w", err)
102+ }
103+ defer rsp.Body.Close()
104+ if rsp.StatusCode != http.StatusOK {
105+ return nil, false, fmt.Errorf("failed to import copilot: status code %d", rsp.StatusCode)
106+ }
107+ var result struct {
108+ Token *oauth.Token `json:"token"`
109+ Success bool `json:"success"`
110+ }
111+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
112+ return nil, false, fmt.Errorf("failed to decode import copilot response: %w", err)
113+ }
114+ return result.Token, result.Success, nil
115+}
116+
117+// RefreshOAuthToken refreshes an OAuth token for a provider on the
118+// server.
119+func (c *Client) RefreshOAuthToken(ctx context.Context, id string, scope config.Scope, providerID string) error {
120+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/refresh-oauth", id), nil, jsonBody(struct {
121+ Scope config.Scope `json:"scope"`
122+ ProviderID string `json:"provider_id"`
123+ }{Scope: scope, ProviderID: providerID}), http.Header{"Content-Type": []string{"application/json"}})
124+ if err != nil {
125+ return fmt.Errorf("failed to refresh OAuth token: %w", err)
126+ }
127+ defer rsp.Body.Close()
128+ if rsp.StatusCode != http.StatusOK {
129+ return fmt.Errorf("failed to refresh OAuth token: status code %d", rsp.StatusCode)
130+ }
131+ return nil
132+}
133+
134+// ProjectNeedsInitialization checks if the project needs
135+// initialization.
136+func (c *Client) ProjectNeedsInitialization(ctx context.Context, id string) (bool, error) {
137+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/project/needs-init", id), nil, nil)
138+ if err != nil {
139+ return false, fmt.Errorf("failed to check project init: %w", err)
140+ }
141+ defer rsp.Body.Close()
142+ if rsp.StatusCode != http.StatusOK {
143+ return false, fmt.Errorf("failed to check project init: status code %d", rsp.StatusCode)
144+ }
145+ var result struct {
146+ NeedsInit bool `json:"needs_init"`
147+ }
148+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
149+ return false, fmt.Errorf("failed to decode project init response: %w", err)
150+ }
151+ return result.NeedsInit, nil
152+}
153+
154+// MarkProjectInitialized marks the project as initialized on the
155+// server.
156+func (c *Client) MarkProjectInitialized(ctx context.Context, id string) error {
157+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/project/init", id), nil, nil, nil)
158+ if err != nil {
159+ return fmt.Errorf("failed to mark project initialized: %w", err)
160+ }
161+ defer rsp.Body.Close()
162+ if rsp.StatusCode != http.StatusOK {
163+ return fmt.Errorf("failed to mark project initialized: status code %d", rsp.StatusCode)
164+ }
165+ return nil
166+}
167+
168+// GetInitializePrompt retrieves the initialization prompt from the
169+// server.
170+func (c *Client) GetInitializePrompt(ctx context.Context, id string) (string, error) {
171+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/project/init-prompt", id), nil, nil)
172+ if err != nil {
173+ return "", fmt.Errorf("failed to get init prompt: %w", err)
174+ }
175+ defer rsp.Body.Close()
176+ if rsp.StatusCode != http.StatusOK {
177+ return "", fmt.Errorf("failed to get init prompt: status code %d", rsp.StatusCode)
178+ }
179+ var result struct {
180+ Prompt string `json:"prompt"`
181+ }
182+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
183+ return "", fmt.Errorf("failed to decode init prompt response: %w", err)
184+ }
185+ return result.Prompt, nil
186+}
187+
188+// MCPResourceContents holds the contents of an MCP resource.
189+type MCPResourceContents struct {
190+ URI string `json:"uri"`
191+ MIMEType string `json:"mime_type,omitempty"`
192+ Text string `json:"text,omitempty"`
193+ Blob []byte `json:"blob,omitempty"`
194+}
195+
196+// EnableDockerMCP enables the Docker MCP server on the workspace.
197+func (c *Client) EnableDockerMCP(ctx context.Context, id string) error {
198+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/docker/enable", id), nil, nil, nil)
199+ if err != nil {
200+ return fmt.Errorf("failed to enable docker MCP: %w", err)
201+ }
202+ defer rsp.Body.Close()
203+ if rsp.StatusCode != http.StatusOK {
204+ return fmt.Errorf("failed to enable docker MCP: status code %d", rsp.StatusCode)
205+ }
206+ return nil
207+}
208+
209+// DisableDockerMCP disables the Docker MCP server on the workspace.
210+func (c *Client) DisableDockerMCP(ctx context.Context, id string) error {
211+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/docker/disable", id), nil, nil, nil)
212+ if err != nil {
213+ return fmt.Errorf("failed to disable docker MCP: %w", err)
214+ }
215+ defer rsp.Body.Close()
216+ if rsp.StatusCode != http.StatusOK {
217+ return fmt.Errorf("failed to disable docker MCP: status code %d", rsp.StatusCode)
218+ }
219+ return nil
220+}
221+
222+// RefreshMCPTools refreshes tools for a named MCP server.
223+func (c *Client) RefreshMCPTools(ctx context.Context, id, name string) error {
224+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-tools", id), nil, jsonBody(struct {
225+ Name string `json:"name"`
226+ }{Name: name}), http.Header{"Content-Type": []string{"application/json"}})
227+ if err != nil {
228+ return fmt.Errorf("failed to refresh MCP tools: %w", err)
229+ }
230+ defer rsp.Body.Close()
231+ if rsp.StatusCode != http.StatusOK {
232+ return fmt.Errorf("failed to refresh MCP tools: status code %d", rsp.StatusCode)
233+ }
234+ return nil
235+}
236+
237+// ReadMCPResource reads a resource from a named MCP server.
238+func (c *Client) ReadMCPResource(ctx context.Context, id, name, uri string) ([]MCPResourceContents, error) {
239+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/read-resource", id), nil, jsonBody(struct {
240+ Name string `json:"name"`
241+ URI string `json:"uri"`
242+ }{Name: name, URI: uri}), http.Header{"Content-Type": []string{"application/json"}})
243+ if err != nil {
244+ return nil, fmt.Errorf("failed to read MCP resource: %w", err)
245+ }
246+ defer rsp.Body.Close()
247+ if rsp.StatusCode != http.StatusOK {
248+ return nil, fmt.Errorf("failed to read MCP resource: status code %d", rsp.StatusCode)
249+ }
250+ var contents []MCPResourceContents
251+ if err := json.NewDecoder(rsp.Body).Decode(&contents); err != nil {
252+ return nil, fmt.Errorf("failed to decode MCP resource: %w", err)
253+ }
254+ return contents, nil
255+}
256+
257+// GetMCPPrompt retrieves a prompt from a named MCP server.
258+func (c *Client) GetMCPPrompt(ctx context.Context, id, clientID, promptID string, args map[string]string) (string, error) {
259+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/get-prompt", id), nil, jsonBody(struct {
260+ ClientID string `json:"client_id"`
261+ PromptID string `json:"prompt_id"`
262+ Args map[string]string `json:"args"`
263+ }{ClientID: clientID, PromptID: promptID, Args: args}), http.Header{"Content-Type": []string{"application/json"}})
264+ if err != nil {
265+ return "", fmt.Errorf("failed to get MCP prompt: %w", err)
266+ }
267+ defer rsp.Body.Close()
268+ if rsp.StatusCode != http.StatusOK {
269+ return "", fmt.Errorf("failed to get MCP prompt: status code %d", rsp.StatusCode)
270+ }
271+ var result struct {
272+ Prompt string `json:"prompt"`
273+ }
274+ if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
275+ return "", fmt.Errorf("failed to decode MCP prompt response: %w", err)
276+ }
277+ return result.Prompt, nil
278+}
internal/client/dial_other.gonew file+14
@@ -0,0 +1,14 @@
1+//go:build !windows
2+// +build !windows
3+
4+package client
5+
6+import (
7+ "context"
8+ "net"
9+ "syscall"
10+)
11+
12+func dialPipeContext(context.Context, string) (net.Conn, error) {
13+ return nil, syscall.EAFNOSUPPORT
14+}
internal/client/dial_windows.gonew file+15
@@ -0,0 +1,15 @@
1+//go:build windows
2+// +build windows
3+
4+package client
5+
6+import (
7+ "context"
8+ "net"
9+
10+ "github.com/Microsoft/go-winio"
11+)
12+
13+func dialPipeContext(ctx context.Context, address string) (net.Conn, error) {
14+ return winio.DialPipeContext(ctx, address)
15+}
internal/client/proto.gonew file+750
@@ -0,0 +1,750 @@
1+package client
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "log/slog"
12+ "net/http"
13+ "net/url"
14+ "time"
15+
16+ "github.com/charmbracelet/crush/internal/config"
17+ "github.com/charmbracelet/crush/internal/message"
18+ "github.com/charmbracelet/crush/internal/proto"
19+ "github.com/charmbracelet/crush/internal/pubsub"
20+ "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
21+)
22+
23+// ListWorkspaces retrieves all workspaces from the server.
24+func (c *Client) ListWorkspaces(ctx context.Context) ([]proto.Workspace, error) {
25+ rsp, err := c.get(ctx, "/workspaces", nil, nil)
26+ if err != nil {
27+ return nil, fmt.Errorf("failed to list workspaces: %w", err)
28+ }
29+ defer rsp.Body.Close()
30+ if rsp.StatusCode != http.StatusOK {
31+ return nil, fmt.Errorf("failed to list workspaces: status code %d", rsp.StatusCode)
32+ }
33+ var workspaces []proto.Workspace
34+ if err := json.NewDecoder(rsp.Body).Decode(&workspaces); err != nil {
35+ return nil, fmt.Errorf("failed to decode workspaces: %w", err)
36+ }
37+ return workspaces, nil
38+}
39+
40+// CreateWorkspace creates a new workspace on the server.
41+func (c *Client) CreateWorkspace(ctx context.Context, ws proto.Workspace) (*proto.Workspace, error) {
42+ rsp, err := c.post(ctx, "/workspaces", nil, jsonBody(ws), http.Header{"Content-Type": []string{"application/json"}})
43+ if err != nil {
44+ return nil, fmt.Errorf("failed to create workspace: %w", err)
45+ }
46+ defer rsp.Body.Close()
47+ if rsp.StatusCode != http.StatusOK {
48+ return nil, fmt.Errorf("failed to create workspace: status code %d", rsp.StatusCode)
49+ }
50+ var created proto.Workspace
51+ if err := json.NewDecoder(rsp.Body).Decode(&created); err != nil {
52+ return nil, fmt.Errorf("failed to decode workspace: %w", err)
53+ }
54+ return &created, nil
55+}
56+
57+// GetWorkspace retrieves a workspace from the server.
58+func (c *Client) GetWorkspace(ctx context.Context, id string) (*proto.Workspace, error) {
59+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s", id), nil, nil)
60+ if err != nil {
61+ return nil, fmt.Errorf("failed to get workspace: %w", err)
62+ }
63+ defer rsp.Body.Close()
64+ if rsp.StatusCode != http.StatusOK {
65+ return nil, fmt.Errorf("failed to get workspace: status code %d", rsp.StatusCode)
66+ }
67+ var ws proto.Workspace
68+ if err := json.NewDecoder(rsp.Body).Decode(&ws); err != nil {
69+ return nil, fmt.Errorf("failed to decode workspace: %w", err)
70+ }
71+ return &ws, nil
72+}
73+
74+// DeleteWorkspace deletes a workspace on the server.
75+func (c *Client) DeleteWorkspace(ctx context.Context, id string) error {
76+ rsp, err := c.delete(ctx, fmt.Sprintf("/workspaces/%s", id), nil, nil)
77+ if err != nil {
78+ return fmt.Errorf("failed to delete workspace: %w", err)
79+ }
80+ defer rsp.Body.Close()
81+ if rsp.StatusCode != http.StatusOK {
82+ return fmt.Errorf("failed to delete workspace: status code %d", rsp.StatusCode)
83+ }
84+ return nil
85+}
86+
87+// SubscribeEvents subscribes to server-sent events for a workspace.
88+func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, error) {
89+ events := make(chan any, 100)
90+ //nolint:bodyclose
91+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/events", id), nil, http.Header{
92+ "Accept": []string{"text/event-stream"},
93+ "Cache-Control": []string{"no-cache"},
94+ "Connection": []string{"keep-alive"},
95+ })
96+ if err != nil {
97+ return nil, fmt.Errorf("failed to subscribe to events: %w", err)
98+ }
99+
100+ if rsp.StatusCode != http.StatusOK {
101+ rsp.Body.Close()
102+ return nil, fmt.Errorf("failed to subscribe to events: status code %d", rsp.StatusCode)
103+ }
104+
105+ go func() {
106+ defer rsp.Body.Close()
107+
108+ scr := bufio.NewReader(rsp.Body)
109+ for {
110+ line, err := scr.ReadBytes('\n')
111+ if errors.Is(err, io.EOF) {
112+ break
113+ }
114+ if err != nil {
115+ slog.Error("Reading from events stream", "error", err)
116+ time.Sleep(time.Second * 2)
117+ continue
118+ }
119+ line = bytes.TrimSpace(line)
120+ if len(line) == 0 {
121+ continue
122+ }
123+
124+ data, ok := bytes.CutPrefix(line, []byte("data:"))
125+ if !ok {
126+ slog.Warn("Invalid event format", "line", string(line))
127+ continue
128+ }
129+
130+ data = bytes.TrimSpace(data)
131+
132+ var p pubsub.Payload
133+ if err := json.Unmarshal(data, &p); err != nil {
134+ slog.Error("Unmarshaling event envelope", "error", err)
135+ continue
136+ }
137+
138+ switch p.Type {
139+ case pubsub.PayloadTypeLSPEvent:
140+ var e pubsub.Event[proto.LSPEvent]
141+ _ = json.Unmarshal(p.Payload, &e)
142+ sendEvent(ctx, events, e)
143+ case pubsub.PayloadTypeMCPEvent:
144+ var e pubsub.Event[proto.MCPEvent]
145+ _ = json.Unmarshal(p.Payload, &e)
146+ sendEvent(ctx, events, e)
147+ case pubsub.PayloadTypePermissionRequest:
148+ var e pubsub.Event[proto.PermissionRequest]
149+ _ = json.Unmarshal(p.Payload, &e)
150+ sendEvent(ctx, events, e)
151+ case pubsub.PayloadTypePermissionNotification:
152+ var e pubsub.Event[proto.PermissionNotification]
153+ _ = json.Unmarshal(p.Payload, &e)
154+ sendEvent(ctx, events, e)
155+ case pubsub.PayloadTypeMessage:
156+ var e pubsub.Event[proto.Message]
157+ _ = json.Unmarshal(p.Payload, &e)
158+ sendEvent(ctx, events, e)
159+ case pubsub.PayloadTypeSession:
160+ var e pubsub.Event[proto.Session]
161+ _ = json.Unmarshal(p.Payload, &e)
162+ sendEvent(ctx, events, e)
163+ case pubsub.PayloadTypeFile:
164+ var e pubsub.Event[proto.File]
165+ _ = json.Unmarshal(p.Payload, &e)
166+ sendEvent(ctx, events, e)
167+ case pubsub.PayloadTypeAgentEvent:
168+ var e pubsub.Event[proto.AgentEvent]
169+ _ = json.Unmarshal(p.Payload, &e)
170+ sendEvent(ctx, events, e)
171+ default:
172+ slog.Warn("Unknown event type", "type", p.Type)
173+ continue
174+ }
175+ }
176+ }()
177+
178+ return events, nil
179+}
180+
181+func sendEvent(ctx context.Context, evc chan any, ev any) {
182+ slog.Info("Event received", "event", fmt.Sprintf("%T %+v", ev, ev))
183+ select {
184+ case evc <- ev:
185+ case <-ctx.Done():
186+ close(evc)
187+ return
188+ }
189+}
190+
191+// GetLSPDiagnostics retrieves LSP diagnostics for a specific LSP client.
192+func (c *Client) GetLSPDiagnostics(ctx context.Context, id string, lspName string) (map[protocol.DocumentURI][]protocol.Diagnostic, error) {
193+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/lsps/%s/diagnostics", id, lspName), nil, nil)
194+ if err != nil {
195+ return nil, fmt.Errorf("failed to get LSP diagnostics: %w", err)
196+ }
197+ defer rsp.Body.Close()
198+ if rsp.StatusCode != http.StatusOK {
199+ return nil, fmt.Errorf("failed to get LSP diagnostics: status code %d", rsp.StatusCode)
200+ }
201+ var diagnostics map[protocol.DocumentURI][]protocol.Diagnostic
202+ if err := json.NewDecoder(rsp.Body).Decode(&diagnostics); err != nil {
203+ return nil, fmt.Errorf("failed to decode LSP diagnostics: %w", err)
204+ }
205+ return diagnostics, nil
206+}
207+
208+// GetLSPs retrieves the LSP client states for a workspace.
209+func (c *Client) GetLSPs(ctx context.Context, id string) (map[string]proto.LSPClientInfo, error) {
210+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/lsps", id), nil, nil)
211+ if err != nil {
212+ return nil, fmt.Errorf("failed to get LSPs: %w", err)
213+ }
214+ defer rsp.Body.Close()
215+ if rsp.StatusCode != http.StatusOK {
216+ return nil, fmt.Errorf("failed to get LSPs: status code %d", rsp.StatusCode)
217+ }
218+ var lsps map[string]proto.LSPClientInfo
219+ if err := json.NewDecoder(rsp.Body).Decode(&lsps); err != nil {
220+ return nil, fmt.Errorf("failed to decode LSPs: %w", err)
221+ }
222+ return lsps, nil
223+}
224+
225+// MCPGetStates retrieves the MCP client states for a workspace.
226+func (c *Client) MCPGetStates(ctx context.Context, id string) (map[string]proto.MCPClientInfo, error) {
227+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/mcp/states", id), nil, nil)
228+ if err != nil {
229+ return nil, fmt.Errorf("failed to get MCP states: %w", err)
230+ }
231+ defer rsp.Body.Close()
232+ if rsp.StatusCode != http.StatusOK {
233+ return nil, fmt.Errorf("failed to get MCP states: status code %d", rsp.StatusCode)
234+ }
235+ var states map[string]proto.MCPClientInfo
236+ if err := json.NewDecoder(rsp.Body).Decode(&states); err != nil {
237+ return nil, fmt.Errorf("failed to decode MCP states: %w", err)
238+ }
239+ return states, nil
240+}
241+
242+// MCPRefreshPrompts refreshes prompts for a named MCP client.
243+func (c *Client) MCPRefreshPrompts(ctx context.Context, id, name string) error {
244+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-prompts", id), nil,
245+ jsonBody(struct {
246+ Name string `json:"name"`
247+ }{Name: name}),
248+ http.Header{"Content-Type": []string{"application/json"}})
249+ if err != nil {
250+ return fmt.Errorf("failed to refresh MCP prompts: %w", err)
251+ }
252+ defer rsp.Body.Close()
253+ if rsp.StatusCode != http.StatusOK {
254+ return fmt.Errorf("failed to refresh MCP prompts: status code %d", rsp.StatusCode)
255+ }
256+ return nil
257+}
258+
259+// MCPRefreshResources refreshes resources for a named MCP client.
260+func (c *Client) MCPRefreshResources(ctx context.Context, id, name string) error {
261+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-resources", id), nil,
262+ jsonBody(struct {
263+ Name string `json:"name"`
264+ }{Name: name}),
265+ http.Header{"Content-Type": []string{"application/json"}})
266+ if err != nil {
267+ return fmt.Errorf("failed to refresh MCP resources: %w", err)
268+ }
269+ defer rsp.Body.Close()
270+ if rsp.StatusCode != http.StatusOK {
271+ return fmt.Errorf("failed to refresh MCP resources: status code %d", rsp.StatusCode)
272+ }
273+ return nil
274+}
275+
276+// GetAgentSessionQueuedPrompts retrieves the number of queued prompts for a
277+// session.
278+func (c *Client) GetAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) (int, error) {
279+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/queued", id, sessionID), nil, nil)
280+ if err != nil {
281+ return 0, fmt.Errorf("failed to get session agent queued prompts: %w", err)
282+ }
283+ defer rsp.Body.Close()
284+ if rsp.StatusCode != http.StatusOK {
285+ return 0, fmt.Errorf("failed to get session agent queued prompts: status code %d", rsp.StatusCode)
286+ }
287+ var count int
288+ if err := json.NewDecoder(rsp.Body).Decode(&count); err != nil {
289+ return 0, fmt.Errorf("failed to decode session agent queued prompts: %w", err)
290+ }
291+ return count, nil
292+}
293+
294+// ClearAgentSessionQueuedPrompts clears the queued prompts for a session.
295+func (c *Client) ClearAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) error {
296+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/clear", id, sessionID), nil, nil, nil)
297+ if err != nil {
298+ return fmt.Errorf("failed to clear session agent queued prompts: %w", err)
299+ }
300+ defer rsp.Body.Close()
301+ if rsp.StatusCode != http.StatusOK {
302+ return fmt.Errorf("failed to clear session agent queued prompts: status code %d", rsp.StatusCode)
303+ }
304+ return nil
305+}
306+
307+// GetAgentInfo retrieves the agent status for a workspace.
308+func (c *Client) GetAgentInfo(ctx context.Context, id string) (*proto.AgentInfo, error) {
309+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent", id), nil, nil)
310+ if err != nil {
311+ return nil, fmt.Errorf("failed to get agent status: %w", err)
312+ }
313+ defer rsp.Body.Close()
314+ if rsp.StatusCode != http.StatusOK {
315+ return nil, fmt.Errorf("failed to get agent status: status code %d", rsp.StatusCode)
316+ }
317+ var info proto.AgentInfo
318+ if err := json.NewDecoder(rsp.Body).Decode(&info); err != nil {
319+ return nil, fmt.Errorf("failed to decode agent status: %w", err)
320+ }
321+ return &info, nil
322+}
323+
324+// UpdateAgent triggers an agent model update on the server.
325+func (c *Client) UpdateAgent(ctx context.Context, id string) error {
326+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/update", id), nil, nil, nil)
327+ if err != nil {
328+ return fmt.Errorf("failed to update agent: %w", err)
329+ }
330+ defer rsp.Body.Close()
331+ if rsp.StatusCode != http.StatusOK {
332+ return fmt.Errorf("failed to update agent: status code %d", rsp.StatusCode)
333+ }
334+ return nil
335+}
336+
337+// SendMessage sends a message to the agent for a workspace.
338+func (c *Client) SendMessage(ctx context.Context, id string, sessionID, prompt string, attachments ...message.Attachment) error {
339+ protoAttachments := make([]proto.Attachment, len(attachments))
340+ for i, a := range attachments {
341+ protoAttachments[i] = proto.Attachment{
342+ FilePath: a.FilePath,
343+ FileName: a.FileName,
344+ MimeType: a.MimeType,
345+ Content: a.Content,
346+ }
347+ }
348+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent", id), nil, jsonBody(proto.AgentMessage{
349+ SessionID: sessionID,
350+ Prompt: prompt,
351+ Attachments: protoAttachments,
352+ }), http.Header{"Content-Type": []string{"application/json"}})
353+ if err != nil {
354+ return fmt.Errorf("failed to send message to agent: %w", err)
355+ }
356+ defer rsp.Body.Close()
357+ if rsp.StatusCode != http.StatusOK {
358+ return fmt.Errorf("failed to send message to agent: status code %d", rsp.StatusCode)
359+ }
360+ return nil
361+}
362+
363+// GetAgentSessionInfo retrieves the agent session info for a workspace.
364+func (c *Client) GetAgentSessionInfo(ctx context.Context, id string, sessionID string) (*proto.AgentSession, error) {
365+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s", id, sessionID), nil, nil)
366+ if err != nil {
367+ return nil, fmt.Errorf("failed to get session agent info: %w", err)
368+ }
369+ defer rsp.Body.Close()
370+ if rsp.StatusCode != http.StatusOK {
371+ return nil, fmt.Errorf("failed to get session agent info: status code %d", rsp.StatusCode)
372+ }
373+ var info proto.AgentSession
374+ if err := json.NewDecoder(rsp.Body).Decode(&info); err != nil {
375+ return nil, fmt.Errorf("failed to decode session agent info: %w", err)
376+ }
377+ return &info, nil
378+}
379+
380+// AgentSummarizeSession requests a session summarization.
381+func (c *Client) AgentSummarizeSession(ctx context.Context, id string, sessionID string) error {
382+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/summarize", id, sessionID), nil, nil, nil)
383+ if err != nil {
384+ return fmt.Errorf("failed to summarize session: %w", err)
385+ }
386+ defer rsp.Body.Close()
387+ if rsp.StatusCode != http.StatusOK {
388+ return fmt.Errorf("failed to summarize session: status code %d", rsp.StatusCode)
389+ }
390+ return nil
391+}
392+
393+// InitiateAgentProcessing triggers agent initialization on the server.
394+func (c *Client) InitiateAgentProcessing(ctx context.Context, id string) error {
395+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/init", id), nil, nil, nil)
396+ if err != nil {
397+ return fmt.Errorf("failed to initiate session agent processing: %w", err)
398+ }
399+ defer rsp.Body.Close()
400+ if rsp.StatusCode != http.StatusOK {
401+ return fmt.Errorf("failed to initiate session agent processing: status code %d", rsp.StatusCode)
402+ }
403+ return nil
404+}
405+
406+// ListMessages retrieves all messages for a session as proto types.
407+func (c *Client) ListMessages(ctx context.Context, id string, sessionID string) ([]proto.Message, error) {
408+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/messages", id, sessionID), nil, nil)
409+ if err != nil {
410+ return nil, fmt.Errorf("failed to get messages: %w", err)
411+ }
412+ defer rsp.Body.Close()
413+ if rsp.StatusCode != http.StatusOK {
414+ return nil, fmt.Errorf("failed to get messages: status code %d", rsp.StatusCode)
415+ }
416+ var msgs []proto.Message
417+ if err := json.NewDecoder(rsp.Body).Decode(&msgs); err != nil && !errors.Is(err, io.EOF) {
418+ return nil, fmt.Errorf("failed to decode messages: %w", err)
419+ }
420+ return msgs, nil
421+}
422+
423+// GetSession retrieves a specific session as a proto type.
424+func (c *Client) GetSession(ctx context.Context, id string, sessionID string) (*proto.Session, error) {
425+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sessionID), nil, nil)
426+ if err != nil {
427+ return nil, fmt.Errorf("failed to get session: %w", err)
428+ }
429+ defer rsp.Body.Close()
430+ if rsp.StatusCode != http.StatusOK {
431+ return nil, fmt.Errorf("failed to get session: status code %d", rsp.StatusCode)
432+ }
433+ var sess proto.Session
434+ if err := json.NewDecoder(rsp.Body).Decode(&sess); err != nil {
435+ return nil, fmt.Errorf("failed to decode session: %w", err)
436+ }
437+ return &sess, nil
438+}
439+
440+// ListSessionHistoryFiles retrieves history files for a session as proto types.
441+func (c *Client) ListSessionHistoryFiles(ctx context.Context, id string, sessionID string) ([]proto.File, error) {
442+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/history", id, sessionID), nil, nil)
443+ if err != nil {
444+ return nil, fmt.Errorf("failed to get session history files: %w", err)
445+ }
446+ defer rsp.Body.Close()
447+ if rsp.StatusCode != http.StatusOK {
448+ return nil, fmt.Errorf("failed to get session history files: status code %d", rsp.StatusCode)
449+ }
450+ var files []proto.File
451+ if err := json.NewDecoder(rsp.Body).Decode(&files); err != nil {
452+ return nil, fmt.Errorf("failed to decode session history files: %w", err)
453+ }
454+ return files, nil
455+}
456+
457+// CreateSession creates a new session in a workspace as a proto type.
458+func (c *Client) CreateSession(ctx context.Context, id string, title string) (*proto.Session, error) {
459+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/sessions", id), nil, jsonBody(proto.Session{Title: title}), http.Header{"Content-Type": []string{"application/json"}})
460+ if err != nil {
461+ return nil, fmt.Errorf("failed to create session: %w", err)
462+ }
463+ defer rsp.Body.Close()
464+ if rsp.StatusCode != http.StatusOK {
465+ return nil, fmt.Errorf("failed to create session: status code %d", rsp.StatusCode)
466+ }
467+ var sess proto.Session
468+ if err := json.NewDecoder(rsp.Body).Decode(&sess); err != nil {
469+ return nil, fmt.Errorf("failed to decode session: %w", err)
470+ }
471+ return &sess, nil
472+}
473+
474+// ListSessions lists all sessions in a workspace as proto types.
475+func (c *Client) ListSessions(ctx context.Context, id string) ([]proto.Session, error) {
476+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions", id), nil, nil)
477+ if err != nil {
478+ return nil, fmt.Errorf("failed to get sessions: %w", err)
479+ }
480+ defer rsp.Body.Close()
481+ if rsp.StatusCode != http.StatusOK {
482+ return nil, fmt.Errorf("failed to get sessions: status code %d", rsp.StatusCode)
483+ }
484+ var sessions []proto.Session
485+ if err := json.NewDecoder(rsp.Body).Decode(&sessions); err != nil {
486+ return nil, fmt.Errorf("failed to decode sessions: %w", err)
487+ }
488+ return sessions, nil
489+}
490+
491+// GrantPermission grants a permission on a workspace.
492+func (c *Client) GrantPermission(ctx context.Context, id string, req proto.PermissionGrant) error {
493+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/permissions/grant", id), nil, jsonBody(req), http.Header{"Content-Type": []string{"application/json"}})
494+ if err != nil {
495+ return fmt.Errorf("failed to grant permission: %w", err)
496+ }
497+ defer rsp.Body.Close()
498+ if rsp.StatusCode != http.StatusOK {
499+ return fmt.Errorf("failed to grant permission: status code %d", rsp.StatusCode)
500+ }
501+ return nil
502+}
503+
504+// SetPermissionsSkipRequests sets the skip-requests flag for a workspace.
505+func (c *Client) SetPermissionsSkipRequests(ctx context.Context, id string, skip bool) error {
506+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/permissions/skip", id), nil, jsonBody(proto.PermissionSkipRequest{Skip: skip}), http.Header{"Content-Type": []string{"application/json"}})
507+ if err != nil {
508+ return fmt.Errorf("failed to set permissions skip requests: %w", err)
509+ }
510+ defer rsp.Body.Close()
511+ if rsp.StatusCode != http.StatusOK {
512+ return fmt.Errorf("failed to set permissions skip requests: status code %d", rsp.StatusCode)
513+ }
514+ return nil
515+}
516+
517+// GetPermissionsSkipRequests retrieves the skip-requests flag for a workspace.
518+func (c *Client) GetPermissionsSkipRequests(ctx context.Context, id string) (bool, error) {
519+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/permissions/skip", id), nil, nil)
520+ if err != nil {
521+ return false, fmt.Errorf("failed to get permissions skip requests: %w", err)
522+ }
523+ defer rsp.Body.Close()
524+ if rsp.StatusCode != http.StatusOK {
525+ return false, fmt.Errorf("failed to get permissions skip requests: status code %d", rsp.StatusCode)
526+ }
527+ var skip proto.PermissionSkipRequest
528+ if err := json.NewDecoder(rsp.Body).Decode(&skip); err != nil {
529+ return false, fmt.Errorf("failed to decode permissions skip requests: %w", err)
530+ }
531+ return skip.Skip, nil
532+}
533+
534+// GetConfig retrieves the workspace-specific configuration.
535+func (c *Client) GetConfig(ctx context.Context, id string) (*config.Config, error) {
536+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/config", id), nil, nil)
537+ if err != nil {
538+ return nil, fmt.Errorf("failed to get config: %w", err)
539+ }
540+ defer rsp.Body.Close()
541+ if rsp.StatusCode != http.StatusOK {
542+ return nil, fmt.Errorf("failed to get config: status code %d", rsp.StatusCode)
543+ }
544+ var cfg config.Config
545+ if err := json.NewDecoder(rsp.Body).Decode(&cfg); err != nil {
546+ return nil, fmt.Errorf("failed to decode config: %w", err)
547+ }
548+ return &cfg, nil
549+}
550+
551+func jsonBody(v any) *bytes.Buffer {
552+ b := new(bytes.Buffer)
553+ m, _ := json.Marshal(v)
554+ b.Write(m)
555+ return b
556+}
557+
558+// SaveSession updates a session in a workspace, returning a proto type.
559+func (c *Client) SaveSession(ctx context.Context, id string, sess proto.Session) (*proto.Session, error) {
560+ rsp, err := c.put(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sess.ID), nil, jsonBody(sess), http.Header{"Content-Type": []string{"application/json"}})
561+ if err != nil {
562+ return nil, fmt.Errorf("failed to save session: %w", err)
563+ }
564+ defer rsp.Body.Close()
565+ if rsp.StatusCode != http.StatusOK {
566+ return nil, fmt.Errorf("failed to save session: status code %d", rsp.StatusCode)
567+ }
568+ var saved proto.Session
569+ if err := json.NewDecoder(rsp.Body).Decode(&saved); err != nil {
570+ return nil, fmt.Errorf("failed to decode session: %w", err)
571+ }
572+ return &saved, nil
573+}
574+
575+// DeleteSession deletes a session from a workspace.
576+func (c *Client) DeleteSession(ctx context.Context, id string, sessionID string) error {
577+ rsp, err := c.delete(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sessionID), nil, nil)
578+ if err != nil {
579+ return fmt.Errorf("failed to delete session: %w", err)
580+ }
581+ defer rsp.Body.Close()
582+ if rsp.StatusCode != http.StatusOK {
583+ return fmt.Errorf("failed to delete session: status code %d", rsp.StatusCode)
584+ }
585+ return nil
586+}
587+
588+// ListUserMessages retrieves user-role messages for a session as proto types.
589+func (c *Client) ListUserMessages(ctx context.Context, id string, sessionID string) ([]proto.Message, error) {
590+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/messages/user", id, sessionID), nil, nil)
591+ if err != nil {
592+ return nil, fmt.Errorf("failed to get user messages: %w", err)
593+ }
594+ defer rsp.Body.Close()
595+ if rsp.StatusCode != http.StatusOK {
596+ return nil, fmt.Errorf("failed to get user messages: status code %d", rsp.StatusCode)
597+ }
598+ var msgs []proto.Message
599+ if err := json.NewDecoder(rsp.Body).Decode(&msgs); err != nil && !errors.Is(err, io.EOF) {
600+ return nil, fmt.Errorf("failed to decode user messages: %w", err)
601+ }
602+ return msgs, nil
603+}
604+
605+// ListAllUserMessages retrieves all user-role messages across sessions as proto types.
606+func (c *Client) ListAllUserMessages(ctx context.Context, id string) ([]proto.Message, error) {
607+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/messages/user", id), nil, nil)
608+ if err != nil {
609+ return nil, fmt.Errorf("failed to get all user messages: %w", err)
610+ }
611+ defer rsp.Body.Close()
612+ if rsp.StatusCode != http.StatusOK {
613+ return nil, fmt.Errorf("failed to get all user messages: status code %d", rsp.StatusCode)
614+ }
615+ var msgs []proto.Message
616+ if err := json.NewDecoder(rsp.Body).Decode(&msgs); err != nil && !errors.Is(err, io.EOF) {
617+ return nil, fmt.Errorf("failed to decode all user messages: %w", err)
618+ }
619+ return msgs, nil
620+}
621+
622+// CancelAgentSession cancels an ongoing agent operation for a session.
623+func (c *Client) CancelAgentSession(ctx context.Context, id string, sessionID string) error {
624+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/cancel", id, sessionID), nil, nil, nil)
625+ if err != nil {
626+ return fmt.Errorf("failed to cancel agent session: %w", err)
627+ }
628+ defer rsp.Body.Close()
629+ if rsp.StatusCode != http.StatusOK {
630+ return fmt.Errorf("failed to cancel agent session: status code %d", rsp.StatusCode)
631+ }
632+ return nil
633+}
634+
635+// GetAgentSessionQueuedPromptsList retrieves the list of queued prompt
636+// strings for a session.
637+func (c *Client) GetAgentSessionQueuedPromptsList(ctx context.Context, id string, sessionID string) ([]string, error) {
638+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/list", id, sessionID), nil, nil)
639+ if err != nil {
640+ return nil, fmt.Errorf("failed to get queued prompts list: %w", err)
641+ }
642+ defer rsp.Body.Close()
643+ if rsp.StatusCode != http.StatusOK {
644+ return nil, fmt.Errorf("failed to get queued prompts list: status code %d", rsp.StatusCode)
645+ }
646+ var prompts []string
647+ if err := json.NewDecoder(rsp.Body).Decode(&prompts); err != nil {
648+ return nil, fmt.Errorf("failed to decode queued prompts list: %w", err)
649+ }
650+ return prompts, nil
651+}
652+
653+// GetDefaultSmallModel retrieves the default small model for a provider.
654+func (c *Client) GetDefaultSmallModel(ctx context.Context, id string, providerID string) (*config.SelectedModel, error) {
655+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/default-small-model", id), url.Values{"provider_id": []string{providerID}}, nil)
656+ if err != nil {
657+ return nil, fmt.Errorf("failed to get default small model: %w", err)
658+ }
659+ defer rsp.Body.Close()
660+ if rsp.StatusCode != http.StatusOK {
661+ return nil, fmt.Errorf("failed to get default small model: status code %d", rsp.StatusCode)
662+ }
663+ var model config.SelectedModel
664+ if err := json.NewDecoder(rsp.Body).Decode(&model); err != nil {
665+ return nil, fmt.Errorf("failed to decode default small model: %w", err)
666+ }
667+ return &model, nil
668+}
669+
670+// FileTrackerRecordRead records a file read for a session.
671+func (c *Client) FileTrackerRecordRead(ctx context.Context, id string, sessionID, path string) error {
672+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/filetracker/read", id), nil, jsonBody(struct {
673+ SessionID string `json:"session_id"`
674+ Path string `json:"path"`
675+ }{SessionID: sessionID, Path: path}), http.Header{"Content-Type": []string{"application/json"}})
676+ if err != nil {
677+ return fmt.Errorf("failed to record file read: %w", err)
678+ }
679+ defer rsp.Body.Close()
680+ if rsp.StatusCode != http.StatusOK {
681+ return fmt.Errorf("failed to record file read: status code %d", rsp.StatusCode)
682+ }
683+ return nil
684+}
685+
686+// FileTrackerLastReadTime returns the last read time for a file in a
687+// session.
688+func (c *Client) FileTrackerLastReadTime(ctx context.Context, id string, sessionID, path string) (time.Time, error) {
689+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/filetracker/lastread", id), url.Values{
690+ "session_id": []string{sessionID},
691+ "path": []string{path},
692+ }, nil)
693+ if err != nil {
694+ return time.Time{}, fmt.Errorf("failed to get last read time: %w", err)
695+ }
696+ defer rsp.Body.Close()
697+ if rsp.StatusCode != http.StatusOK {
698+ return time.Time{}, fmt.Errorf("failed to get last read time: status code %d", rsp.StatusCode)
699+ }
700+ var t time.Time
701+ if err := json.NewDecoder(rsp.Body).Decode(&t); err != nil {
702+ return time.Time{}, fmt.Errorf("failed to decode last read time: %w", err)
703+ }
704+ return t, nil
705+}
706+
707+// FileTrackerListReadFiles returns the list of read files for a session.
708+func (c *Client) FileTrackerListReadFiles(ctx context.Context, id string, sessionID string) ([]string, error) {
709+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/filetracker/files", id, sessionID), nil, nil)
710+ if err != nil {
711+ return nil, fmt.Errorf("failed to get read files: %w", err)
712+ }
713+ defer rsp.Body.Close()
714+ if rsp.StatusCode != http.StatusOK {
715+ return nil, fmt.Errorf("failed to get read files: status code %d", rsp.StatusCode)
716+ }
717+ var files []string
718+ if err := json.NewDecoder(rsp.Body).Decode(&files); err != nil {
719+ return nil, fmt.Errorf("failed to decode read files: %w", err)
720+ }
721+ return files, nil
722+}
723+
724+// LSPStart starts an LSP server for a path.
725+func (c *Client) LSPStart(ctx context.Context, id string, path string) error {
726+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/lsps/start", id), nil, jsonBody(struct {
727+ Path string `json:"path"`
728+ }{Path: path}), http.Header{"Content-Type": []string{"application/json"}})
729+ if err != nil {
730+ return fmt.Errorf("failed to start LSP: %w", err)
731+ }
732+ defer rsp.Body.Close()
733+ if rsp.StatusCode != http.StatusOK {
734+ return fmt.Errorf("failed to start LSP: status code %d", rsp.StatusCode)
735+ }
736+ return nil
737+}
738+
739+// LSPStopAll stops all LSP servers for a workspace.
740+func (c *Client) LSPStopAll(ctx context.Context, id string) error {
741+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/lsps/stop", id), nil, nil, nil)
742+ if err != nil {
743+ return fmt.Errorf("failed to stop LSPs: %w", err)
744+ }
745+ defer rsp.Body.Close()
746+ if rsp.StatusCode != http.StatusOK {
747+ return fmt.Errorf("failed to stop LSPs: status code %d", rsp.StatusCode)
748+ }
749+ return nil
750+}
internal/cmd/login.go+28−17
@@ -10,10 +10,12 @@ import (
10 "charm.land/lipgloss/v2"
10 "charm.land/lipgloss/v2"
11 "github.com/atotto/clipboard"
11 "github.com/atotto/clipboard"
12 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
12 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
13+ "github.com/charmbracelet/crush/internal/client"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/oauth"
15 "github.com/charmbracelet/crush/internal/oauth"
15 "github.com/charmbracelet/crush/internal/oauth/copilot"
16 "github.com/charmbracelet/crush/internal/oauth/copilot"
16 "github.com/charmbracelet/crush/internal/oauth/hyper"
17 "github.com/charmbracelet/crush/internal/oauth/hyper"
18+ "github.com/charmbracelet/x/ansi"
17 "github.com/pkg/browser"
19 "github.com/pkg/browser"
18 "github.com/spf13/cobra"
20 "github.com/spf13/cobra"
19)
21)
@@ -40,11 +42,17 @@ crush login copilot
40 },
42 },
41 Args: cobra.MaximumNArgs(1),
43 Args: cobra.MaximumNArgs(1),
42 RunE: func(cmd *cobra.Command, args []string) error {
44 RunE: func(cmd *cobra.Command, args []string) error {
43− app, err := setupAppWithProgressBar(cmd)
45+ c, ws, cleanup, err := connectToServer(cmd)
44 if err != nil {
46 if err != nil {
45 return err
47 return err
46 }
48 }
47− defer app.Shutdown()
49+ defer cleanup()
50+
51+ progressEnabled := ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
52+ if progressEnabled && supportsProgressBar() {
53+ _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
54+ defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
55+ }
48
56
49 provider := "hyper"
57 provider := "hyper"
50 if len(args) > 0 {
58 if len(args) > 0 {
@@ -52,16 +60,16 @@ crush login copilot
52 }
60 }
53 switch provider {
61 switch provider {
54 case "hyper":
62 case "hyper":
55− return loginHyper(app.Store())
63+ return loginHyper(c, ws.ID)
56 case "copilot", "github", "github-copilot":
64 case "copilot", "github", "github-copilot":
57− return loginCopilot(app.Store())
65+ return loginCopilot(cmd.Context(), c, ws.ID)
58 default:
66 default:
59 return fmt.Errorf("unknown platform: %s", args[0])
67 return fmt.Errorf("unknown platform: %s", args[0])
60 }
68 }
61 },
69 },
62}
70}
63
71
64−func loginHyper(cfg *config.ConfigStore) error {
72+func loginHyper(c *client.Client, wsID string) error {
65 if !hyperp.Enabled() {
73 if !hyperp.Enabled() {
66 return fmt.Errorf("hyper not enabled")
74 return fmt.Errorf("hyper not enabled")
67 }
75 }
@@ -112,8 +120,8 @@ func loginHyper(cfg *config.ConfigStore) error {
112 }
120 }
113
121
114 if err := cmp.Or(
122 if err := cmp.Or(
115− cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
123+ c.SetConfigField(ctx, wsID, config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
116− cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.oauth", token),
124+ c.SetConfigField(ctx, wsID, config.ScopeGlobal, "providers.hyper.oauth", token),
117 ); err != nil {
125 ); err != nil {
118 return err
126 return err
119 }
127 }
@@ -123,12 +131,15 @@ func loginHyper(cfg *config.ConfigStore) error {
123 return nil
131 return nil
124}
132}
125
133
126−func loginCopilot(cfg *config.ConfigStore) error {
134+func loginCopilot(ctx context.Context, c *client.Client, wsID string) error {
127− ctx := getLoginContext()
135+ loginCtx := getLoginContext()
128
136
129− if cfg.HasConfigField(config.ScopeGlobal, "providers.copilot.oauth") {
137+ cfg, err := c.GetConfig(ctx, wsID)
138+ if err == nil && cfg != nil {
139+ if pc, ok := cfg.Providers.Get("copilot"); ok && pc.OAuthToken != nil {
130− fmt.Println("You are already logged in to GitHub Copilot.")
140+ fmt.Println("You are already logged in to GitHub Copilot.")
131− return nil
141+ return nil
142+ }
132 }
143 }
133
144
134 diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
145 diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
@@ -138,14 +149,14 @@ func loginCopilot(cfg *config.ConfigStore) error {
138 case hasDiskToken:
149 case hasDiskToken:
139 fmt.Println("Found existing GitHub Copilot token on disk. Using it to authenticate...")
150 fmt.Println("Found existing GitHub Copilot token on disk. Using it to authenticate...")
140
151
141− t, err := copilot.RefreshToken(ctx, diskToken)
152+ t, err := copilot.RefreshToken(loginCtx, diskToken)
142 if err != nil {
153 if err != nil {
143 return fmt.Errorf("unable to refresh token from disk: %w", err)
154 return fmt.Errorf("unable to refresh token from disk: %w", err)
144 }
155 }
145 token = t
156 token = t
146 default:
157 default:
147 fmt.Println("Requesting device code from GitHub...")
158 fmt.Println("Requesting device code from GitHub...")
148− dc, err := copilot.RequestDeviceCode(ctx)
159+ dc, err := copilot.RequestDeviceCode(loginCtx)
149 if err != nil {
160 if err != nil {
150 return err
161 return err
151 }
162 }
@@ -159,7 +170,7 @@ func loginCopilot(cfg *config.ConfigStore) error {
159 fmt.Println()
170 fmt.Println()
160 fmt.Println("Waiting for authorization...")
171 fmt.Println("Waiting for authorization...")
161
172
162− t, err := copilot.PollForToken(ctx, dc)
173+ t, err := copilot.PollForToken(loginCtx, dc)
163 if err == copilot.ErrNotAvailable {
174 if err == copilot.ErrNotAvailable {
164 fmt.Println()
175 fmt.Println()
165 fmt.Println("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
176 fmt.Println("GitHub Copilot is unavailable for this account. To signup, go to the following page:")
@@ -177,8 +188,8 @@ func loginCopilot(cfg *config.ConfigStore) error {
177 }
188 }
178
189
179 if err := cmp.Or(
190 if err := cmp.Or(
180− cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
191+ c.SetConfigField(loginCtx, wsID, config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
181− cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.oauth", token),
192+ c.SetConfigField(loginCtx, wsID, config.ScopeGlobal, "providers.copilot.oauth", token),
182 ); err != nil {
193 ); err != nil {
183 return err
194 return err
184 }
195 }
internal/cmd/root.go+331−37
@@ -7,24 +7,35 @@ import (
7 "errors"
7 "errors"
8 "fmt"
8 "fmt"
9 "io"
9 "io"
10+ "io/fs"
10 "log/slog"
11 "log/slog"
12+ "net/url"
11 "os"
13 "os"
14+ "os/exec"
12 "path/filepath"
15 "path/filepath"
16+ "regexp"
13 "strconv"
17 "strconv"
14 "strings"
18 "strings"
19+ "time"
15
20
16 tea "charm.land/bubbletea/v2"
21 tea "charm.land/bubbletea/v2"
17− "charm.land/fang/v2"
22+ fang "charm.land/fang/v2"
18 "charm.land/lipgloss/v2"
23 "charm.land/lipgloss/v2"
19 "github.com/charmbracelet/colorprofile"
24 "github.com/charmbracelet/colorprofile"
20 "github.com/charmbracelet/crush/internal/app"
25 "github.com/charmbracelet/crush/internal/app"
26+ "github.com/charmbracelet/crush/internal/client"
21 "github.com/charmbracelet/crush/internal/config"
27 "github.com/charmbracelet/crush/internal/config"
22 "github.com/charmbracelet/crush/internal/db"
28 "github.com/charmbracelet/crush/internal/db"
23 "github.com/charmbracelet/crush/internal/event"
29 "github.com/charmbracelet/crush/internal/event"
30+ crushlog "github.com/charmbracelet/crush/internal/log"
24 "github.com/charmbracelet/crush/internal/projects"
31 "github.com/charmbracelet/crush/internal/projects"
32+ "github.com/charmbracelet/crush/internal/proto"
33+ "github.com/charmbracelet/crush/internal/server"
34+ "github.com/charmbracelet/crush/internal/session"
25 "github.com/charmbracelet/crush/internal/ui/common"
35 "github.com/charmbracelet/crush/internal/ui/common"
26 ui "github.com/charmbracelet/crush/internal/ui/model"
36 ui "github.com/charmbracelet/crush/internal/ui/model"
27 "github.com/charmbracelet/crush/internal/version"
37 "github.com/charmbracelet/crush/internal/version"
38+ "github.com/charmbracelet/crush/internal/workspace"
28 uv "github.com/charmbracelet/ultraviolet"
39 uv "github.com/charmbracelet/ultraviolet"
29 "github.com/charmbracelet/x/ansi"
40 "github.com/charmbracelet/x/ansi"
30 "github.com/charmbracelet/x/exp/charmtone"
41 "github.com/charmbracelet/x/exp/charmtone"
@@ -32,10 +43,13 @@ import (
32 "github.com/spf13/cobra"
43 "github.com/spf13/cobra"
33)
44)
34
45
46+var clientHost string
47+
35func init() {
48func init() {
36 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
49 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
37 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
50 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
38 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
51 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
52+ rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
39 rootCmd.Flags().BoolP("help", "h", false, "Help")
53 rootCmd.Flags().BoolP("help", "h", false, "Help")
40 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
54 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
41 rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
55 rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
@@ -88,15 +102,14 @@ crush --continue
88 sessionID, _ := cmd.Flags().GetString("session")
102 sessionID, _ := cmd.Flags().GetString("session")
89 continueLast, _ := cmd.Flags().GetBool("continue")
103 continueLast, _ := cmd.Flags().GetBool("continue")
90
104
91− app, err := setupAppWithProgressBar(cmd)
105+ ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
92 if err != nil {
106 if err != nil {
93 return err
107 return err
94 }
108 }
95− defer app.Shutdown()
109+ defer cleanup()
96
110
97− // Resolve session ID if provided
98 if sessionID != "" {
111 if sessionID != "" {
99− sess, err := resolveSessionID(cmd.Context(), app.Sessions, sessionID)
112+ sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
100 if err != nil {
113 if err != nil {
101 return err
114 return err
102 }
115 }
@@ -105,19 +118,17 @@ crush --continue
105
118
106 event.AppInitialized()
119 event.AppInitialized()
107
120
108− // Set up the TUI.
109− var env uv.Environ = os.Environ()
110−
111− com := common.DefaultCommon(app)
121+ com := common.DefaultCommon(ws)
112 model := ui.New(com, sessionID, continueLast)
122 model := ui.New(com, sessionID, continueLast)
113
123
124+ var env uv.Environ = os.Environ()
114 program := tea.NewProgram(
125 program := tea.NewProgram(
115 model,
126 model,
116 tea.WithEnvironment(env),
127 tea.WithEnvironment(env),
117 tea.WithContext(cmd.Context()),
128 tea.WithContext(cmd.Context()),
118− tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
129+ tea.WithFilter(ui.MouseEventFilter),
119 )
130 )
120− go app.Subscribe(program)
131+ go ws.Subscribe(program)
121
132
122 if _, err := program.Run(); err != nil {
133 if _, err := program.Run(); err != nil {
123 event.Error(err)
134 event.Error(err)
@@ -147,6 +158,15 @@ const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{
147`
158`
148
159
149func Execute() {
160func Execute() {
161+ // FIXME: config.Load uses slog internally during provider resolution,
162+ // but the file-based logger isn't set up until after config is loaded
163+ // (because the log path depends on the data directory from config).
164+ // This creates a window where slog calls in config.Load leak to
165+ // stderr. We discard early logs here as a workaround. The proper
166+ // fix is to remove slog calls from config.Load and have it return
167+ // warnings/diagnostics instead of logging them as a side effect.
168+ slog.SetDefault(slog.New(slog.DiscardHandler))
169+
150 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
170 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
151 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
171 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
152 // finally prepend it in the version template.
172 // finally prepend it in the version template.
@@ -183,25 +203,44 @@ func supportsProgressBar() bool {
183 return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
203 return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
184}
204}
185
205
186−func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
206+// useClientServer returns true when the client/server architecture is
187− app, err := setupApp(cmd)
207+// enabled via the CRUSH_CLIENT_SERVER environment variable.
188− if err != nil {
208+func useClientServer() bool {
209+ v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
189− return nil, err
210+ return v
190− }
211+}
191
212
192− // Check if progress bar is enabled in config (defaults to true if nil)
213+// setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
193− progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
214+// terminal progress bar shown during initialization.
215+func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
194− if progressEnabled && supportsProgressBar() {
216+ showProgress := supportsProgressBar()
217+ if showProgress {
195 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
218 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
196− defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
197 }
219 }
198
220
221+ ws, cleanup, err := setupWorkspace(cmd)
222+
223+ if showProgress {
224+ _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
225+ }
226+
199− return app, nil
227+ return ws, cleanup, err
228+}
229+
230+// setupWorkspace returns a Workspace and cleanup function. When
231+// CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
232+// ClientWorkspace. Otherwise it creates an in-process app.App and
233+// returns an AppWorkspace.
234+func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
235+ if useClientServer() {
236+ return setupClientServerWorkspace(cmd)
237+ }
238+ return setupLocalWorkspace(cmd)
200}
239}
201
240
202−// setupApp handles the common setup logic for both interactive and non-interactive modes.
241+// setupLocalWorkspace creates an in-process app.App and wraps it in an
203−// It returns the app instance, config, cleanup function, and any error.
242+// AppWorkspace.
204−func setupApp(cmd *cobra.Command) (*app.App, error) {
243+func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
205 debug, _ := cmd.Flags().GetBool("debug")
244 debug, _ := cmd.Flags().GetBool("debug")
206 yolo, _ := cmd.Flags().GetBool("yolo")
245 yolo, _ := cmd.Flags().GetBool("yolo")
207 dataDir, _ := cmd.Flags().GetString("data-dir")
246 dataDir, _ := cmd.Flags().GetString("data-dir")
@@ -209,47 +248,270 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
209
248
210 cwd, err := ResolveCwd(cmd)
249 cwd, err := ResolveCwd(cmd)
211 if err != nil {
250 if err != nil {
212− return nil, err
251+ return nil, nil, err
213 }
252 }
214
253
215 store, err := config.Init(cwd, dataDir, debug)
254 store, err := config.Init(cwd, dataDir, debug)
216 if err != nil {
255 if err != nil {
217− return nil, err
256+ return nil, nil, err
218 }
257 }
219
258
220 cfg := store.Config()
259 cfg := store.Config()
221− if cfg.Permissions == nil {
260+ store.Overrides().SkipPermissionRequests = yolo
222− cfg.Permissions = &config.Permissions{}
261+
262+ if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
263+ return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
223 }
264 }
224− cfg.Permissions.SkipRequests = yolo
225
265
226− if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
266+ gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
227− return nil, err
267+ if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
268+ if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
269+ return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
270+ }
228 }
271 }
229
272
230− // Register this project in the centralized projects list.
231 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
273 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
232 slog.Warn("Failed to register project", "error", err)
274 slog.Warn("Failed to register project", "error", err)
233− // Non-fatal: continue even if registration fails
234 }
275 }
235
276
236− // Connect to DB; this will also run migrations.
237 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
277 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
238 if err != nil {
278 if err != nil {
239− return nil, err
279+ return nil, nil, err
240 }
280 }
241
281
282+ logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
283+ crushlog.Setup(logFile, debug)
284+
242 appInstance, err := app.New(ctx, conn, store)
285 appInstance, err := app.New(ctx, conn, store)
243 if err != nil {
286 if err != nil {
287+ _ = conn.Close()
244 slog.Error("Failed to create app instance", "error", err)
288 slog.Error("Failed to create app instance", "error", err)
245− return nil, err
289+ return nil, nil, err
246 }
290 }
247
291
248 if shouldEnableMetrics(cfg) {
292 if shouldEnableMetrics(cfg) {
249 event.Init()
293 event.Init()
250 }
294 }
251
295
252− return appInstance, nil
296+ ws := workspace.NewAppWorkspace(appInstance, store)
297+ cleanup := func() { appInstance.Shutdown() }
298+ return ws, cleanup, nil
299+}
300+
301+// setupClientServerWorkspace connects to a server process and wraps the
302+// result in a ClientWorkspace.
303+func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
304+ c, protoWs, cleanupServer, err := connectToServer(cmd)
305+ if err != nil {
306+ return nil, nil, err
307+ }
308+
309+ clientWs := workspace.NewClientWorkspace(c, *protoWs)
310+
311+ if protoWs.Config.IsConfigured() {
312+ if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
313+ slog.Error("Failed to initialize coder agent", "error", err)
314+ }
315+ }
316+
317+ return clientWs, cleanupServer, nil
318+}
319+
320+// connectToServer ensures the server is running, creates a client and
321+// workspace, and returns a cleanup function that deletes the workspace.
322+func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
323+ hostURL, err := server.ParseHostURL(clientHost)
324+ if err != nil {
325+ return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
326+ }
327+
328+ if err := ensureServer(cmd, hostURL); err != nil {
329+ return nil, nil, nil, err
330+ }
331+
332+ debug, _ := cmd.Flags().GetBool("debug")
333+ yolo, _ := cmd.Flags().GetBool("yolo")
334+ dataDir, _ := cmd.Flags().GetString("data-dir")
335+ ctx := cmd.Context()
336+
337+ cwd, err := ResolveCwd(cmd)
338+ if err != nil {
339+ return nil, nil, nil, err
340+ }
341+
342+ c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
343+ if err != nil {
344+ return nil, nil, nil, err
345+ }
346+
347+ wsReq := proto.Workspace{
348+ Path: cwd,
349+ DataDir: dataDir,
350+ Debug: debug,
351+ YOLO: yolo,
352+ Version: version.Version,
353+ Env: os.Environ(),
354+ }
355+
356+ ws, err := c.CreateWorkspace(ctx, wsReq)
357+ if err != nil {
358+ // The server socket may exist before the HTTP handler is ready.
359+ // Retry a few times with a short backoff.
360+ for range 5 {
361+ select {
362+ case <-ctx.Done():
363+ return nil, nil, nil, ctx.Err()
364+ case <-time.After(200 * time.Millisecond):
365+ }
366+ ws, err = c.CreateWorkspace(ctx, wsReq)
367+ if err == nil {
368+ break
369+ }
370+ }
371+ if err != nil {
372+ return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
373+ }
374+ }
375+
376+ if shouldEnableMetrics(ws.Config) {
377+ event.Init()
378+ }
379+
380+ if ws.Config != nil {
381+ logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
382+ crushlog.Setup(logFile, debug)
383+ }
384+
385+ cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
386+ return c, ws, cleanup, nil
387+}
388+
389+// ensureServer auto-starts a detached server if the socket file does not
390+// exist. When the socket exists, it verifies that the running server
391+// version matches the client; on mismatch it shuts down the old server
392+// and starts a fresh one.
393+func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
394+ switch hostURL.Scheme {
395+ case "unix", "npipe":
396+ needsStart := false
397+ if _, err := os.Stat(hostURL.Host); err != nil && errors.Is(err, fs.ErrNotExist) {
398+ needsStart = true
399+ } else if err == nil {
400+ if err := restartIfStale(cmd, hostURL); err != nil {
401+ slog.Warn("Failed to check server version, restarting", "error", err)
402+ needsStart = true
403+ }
404+ }
405+
406+ if needsStart {
407+ if err := startDetachedServer(cmd); err != nil {
408+ return err
409+ }
410+ }
411+
412+ var err error
413+ for range 10 {
414+ _, err = os.Stat(hostURL.Host)
415+ if err == nil {
416+ break
417+ }
418+ select {
419+ case <-cmd.Context().Done():
420+ return cmd.Context().Err()
421+ case <-time.After(100 * time.Millisecond):
422+ }
423+ }
424+ if err != nil {
425+ return fmt.Errorf("failed to initialize crush server: %v", err)
426+ }
427+ }
428+
429+ return nil
430+}
431+
432+// restartIfStale checks whether the running server matches the current
433+// client version. When they differ, it sends a shutdown command and
434+// removes the stale socket so the caller can start a fresh server.
435+func restartIfStale(cmd *cobra.Command, hostURL *url.URL) error {
436+ c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
437+ if err != nil {
438+ return err
439+ }
440+ vi, err := c.VersionInfo(cmd.Context())
441+ if err != nil {
442+ return err
443+ }
444+ if vi.Version == version.Version {
445+ return nil
446+ }
447+ slog.Info("Server version mismatch, restarting",
448+ "server", vi.Version,
449+ "client", version.Version,
450+ )
451+ _ = c.ShutdownServer(cmd.Context())
452+ // Give the old process a moment to release the socket.
453+ for range 20 {
454+ if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
455+ break
456+ }
457+ select {
458+ case <-cmd.Context().Done():
459+ return cmd.Context().Err()
460+ case <-time.After(100 * time.Millisecond):
461+ }
462+ }
463+ // Force-remove if the socket is still lingering.
464+ _ = os.Remove(hostURL.Host)
465+ return nil
466+}
467+
468+var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
469+
470+func startDetachedServer(cmd *cobra.Command) error {
471+ exe, err := os.Executable()
472+ if err != nil {
473+ return fmt.Errorf("failed to get executable path: %v", err)
474+ }
475+
476+ safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
477+ chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
478+ if err := os.MkdirAll(chDir, 0o700); err != nil {
479+ return fmt.Errorf("failed to create server working directory: %v", err)
480+ }
481+
482+ cmdArgs := []string{"server"}
483+ if clientHost != server.DefaultHost() {
484+ cmdArgs = append(cmdArgs, "--host", clientHost)
485+ }
486+
487+ c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
488+ stdoutPath := filepath.Join(chDir, "stdout.log")
489+ stderrPath := filepath.Join(chDir, "stderr.log")
490+ detachProcess(c)
491+
492+ stdout, err := os.Create(stdoutPath)
493+ if err != nil {
494+ return fmt.Errorf("failed to create stdout log file: %v", err)
495+ }
496+ defer stdout.Close()
497+ c.Stdout = stdout
498+
499+ stderr, err := os.Create(stderrPath)
500+ if err != nil {
501+ return fmt.Errorf("failed to create stderr log file: %v", err)
502+ }
503+ defer stderr.Close()
504+ c.Stderr = stderr
505+
506+ if err := c.Start(); err != nil {
507+ return fmt.Errorf("failed to start crush server: %v", err)
508+ }
509+
510+ if err := c.Process.Release(); err != nil {
511+ return fmt.Errorf("failed to detach crush server process: %v", err)
512+ }
513+
514+ return nil
253}
515}
254
516
255func shouldEnableMetrics(cfg *config.Config) bool {
517func shouldEnableMetrics(cfg *config.Config) bool {
@@ -284,6 +546,38 @@ func MaybePrependStdin(prompt string) (string, error) {
284 return string(bts) + "\n\n" + prompt, nil
546 return string(bts) + "\n\n" + prompt, nil
285}
547}
286
548
549+// resolveWorkspaceSessionID resolves a session ID that may be a full
550+// UUID, full hash, or hash prefix. Works against the Workspace
551+// interface so both local and client/server paths get hash prefix
552+// support.
553+func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
554+ if sess, err := ws.GetSession(ctx, id); err == nil {
555+ return sess, nil
556+ }
557+
558+ sessions, err := ws.ListSessions(ctx)
559+ if err != nil {
560+ return session.Session{}, err
561+ }
562+
563+ var matches []session.Session
564+ for _, s := range sessions {
565+ hash := session.HashID(s.ID)
566+ if hash == id || strings.HasPrefix(hash, id) {
567+ matches = append(matches, s)
568+ }
569+ }
570+
571+ switch len(matches) {
572+ case 0:
573+ return session.Session{}, fmt.Errorf("session not found: %s", id)
574+ case 1:
575+ return matches[0], nil
576+ default:
577+ return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
578+ }
579+}
580+
287func ResolveCwd(cmd *cobra.Command) (string, error) {
581func ResolveCwd(cmd *cobra.Command) (string, error) {
288 cwd, _ := cmd.Flags().GetString("cwd")
582 cwd, _ := cmd.Flags().GetString("cwd")
289 if cwd != "" {
583 if cwd != "" {
internal/cmd/root_other.gonew file+16
@@ -0,0 +1,16 @@
1+//go:build !windows
2+// +build !windows
3+
4+package cmd
5+
6+import (
7+ "os/exec"
8+ "syscall"
9+)
10+
11+func detachProcess(c *exec.Cmd) {
12+ if c.SysProcAttr == nil {
13+ c.SysProcAttr = &syscall.SysProcAttr{}
14+ }
15+ c.SysProcAttr.Setsid = true
16+}
internal/cmd/root_windows.gonew file+18
@@ -0,0 +1,18 @@
1+//go:build windows
2+// +build windows
3+
4+package cmd
5+
6+import (
7+ "os/exec"
8+ "syscall"
9+
10+ "golang.org/x/sys/windows"
11+)
12+
13+func detachProcess(c *exec.Cmd) {
14+ if c.SysProcAttr == nil {
15+ c.SysProcAttr = &syscall.SysProcAttr{}
16+ }
17+ c.SysProcAttr.CreationFlags = syscall.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS
18+}
internal/cmd/run.go+436−24
@@ -7,9 +7,23 @@ import (
7 "os"
7 "os"
8 "os/signal"
8 "os/signal"
9 "strings"
9 "strings"
10+ "time"
10
11
12+ "charm.land/lipgloss/v2"
11 "charm.land/log/v2"
13 "charm.land/log/v2"
14+ "github.com/charmbracelet/crush/internal/client"
15+ "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/event"
16 "github.com/charmbracelet/crush/internal/event"
17+ "github.com/charmbracelet/crush/internal/format"
18+ "github.com/charmbracelet/crush/internal/proto"
19+ "github.com/charmbracelet/crush/internal/pubsub"
20+ "github.com/charmbracelet/crush/internal/session"
21+ "github.com/charmbracelet/crush/internal/ui/anim"
22+ "github.com/charmbracelet/crush/internal/ui/styles"
23+ "github.com/charmbracelet/crush/internal/workspace"
24+ "github.com/charmbracelet/x/ansi"
25+ "github.com/charmbracelet/x/exp/charmtone"
26+ "github.com/charmbracelet/x/term"
13 "github.com/spf13/cobra"
27 "github.com/spf13/cobra"
14)
28)
15
29
@@ -59,31 +73,9 @@ crush run --continue "Follow up on your last response"
59 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
73 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
60 defer cancel()
74 defer cancel()
61
75
62− app, err := setupApp(cmd)
63− if err != nil {
64− return err
65− }
66− defer app.Shutdown()
67−
68− if sessionID != "" {
69− sess, err := resolveSessionID(ctx, app.Sessions, sessionID)
70− if err != nil {
71− return err
72− }
73− sessionID = sess.ID
74− }
75−
76− if !app.Config().IsConfigured() {
77− return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
78− }
79−
80− if verbose {
81− slog.SetDefault(slog.New(log.New(os.Stderr)))
82− }
83−
84 prompt := strings.Join(args, " ")
76 prompt := strings.Join(args, " ")
85
77
86− prompt, err = MaybePrependStdin(prompt)
78+ prompt, err := MaybePrependStdin(prompt)
87 if err != nil {
79 if err != nil {
88 slog.Error("Failed to read from stdin", "error", err)
80 slog.Error("Failed to read from stdin", "error", err)
89 return err
81 return err
@@ -103,7 +95,48 @@ crush run --continue "Follow up on your last response"
103 event.SetContinueLastSession(true)
95 event.SetContinueLastSession(true)
104 }
96 }
105
97
98+ if useClientServer() {
99+ c, ws, cleanup, err := connectToServer(cmd)
100+ if err != nil {
101+ return err
102+ }
103+ defer cleanup()
104+
105+ if sessionID != "" {
106+ sess, err := resolveSessionByID(ctx, c, ws.ID, sessionID)
107+ if err != nil {
108+ return err
109+ }
110+ sessionID = sess.ID
111+ }
112+
113+ if !ws.Config.IsConfigured() {
114+ return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
115+ }
116+
117+ if verbose {
118+ slog.SetDefault(slog.New(log.New(os.Stderr)))
119+ }
120+
121+ return runNonInteractive(ctx, c, ws, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
122+ }
123+
124+ ws, cleanup, err := setupLocalWorkspace(cmd)
125+ if err != nil {
126+ return err
127+ }
128+ defer cleanup()
129+
130+ if !ws.Config().IsConfigured() {
131+ return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
132+ }
133+
134+ if verbose {
135+ slog.SetDefault(slog.New(log.New(os.Stderr)))
136+ }
137+
138+ appWs := ws.(*workspace.AppWorkspace)
106− return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
139+ return appWs.App().RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
107 },
140 },
108}
141}
109
142
@@ -116,3 +149,382 @@ func init() {
116 runCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
149 runCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
117 runCmd.MarkFlagsMutuallyExclusive("session", "continue")
150 runCmd.MarkFlagsMutuallyExclusive("session", "continue")
118}
151}
152+
153+// runNonInteractive executes the agent via the server and streams output
154+// to stdout.
155+func runNonInteractive(
156+ ctx context.Context,
157+ c *client.Client,
158+ ws *proto.Workspace,
159+ prompt, largeModel, smallModel string,
160+ hideSpinner bool,
161+ continueSessionID string,
162+ useLast bool,
163+) error {
164+ slog.Info("Running in non-interactive mode")
165+
166+ ctx, cancel := context.WithCancel(ctx)
167+ defer cancel()
168+
169+ if largeModel != "" || smallModel != "" {
170+ if err := overrideModels(ctx, c, ws, largeModel, smallModel); err != nil {
171+ return fmt.Errorf("failed to override models: %w", err)
172+ }
173+ }
174+
175+ var (
176+ spinner *format.Spinner
177+ stdoutTTY bool
178+ stderrTTY bool
179+ stdinTTY bool
180+ progress bool
181+ )
182+
183+ stdoutTTY = term.IsTerminal(os.Stdout.Fd())
184+ stderrTTY = term.IsTerminal(os.Stderr.Fd())
185+ stdinTTY = term.IsTerminal(os.Stdin.Fd())
186+ progress = ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
187+
188+ if !hideSpinner && stderrTTY {
189+ t := styles.DefaultStyles()
190+
191+ hasDarkBG := true
192+ if stdinTTY && stdoutTTY {
193+ hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
194+ }
195+ defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
196+
197+ spinner = format.NewSpinner(ctx, cancel, anim.Settings{
198+ Size: 10,
199+ Label: "Generating",
200+ LabelColor: defaultFG,
201+ GradColorA: t.Primary,
202+ GradColorB: t.Secondary,
203+ CycleColors: true,
204+ })
205+ spinner.Start()
206+ }
207+
208+ stopSpinner := func() {
209+ if !hideSpinner && spinner != nil {
210+ spinner.Stop()
211+ spinner = nil
212+ }
213+ }
214+
215+ // Wait for the agent to become ready (MCP init, etc).
216+ if err := waitForAgent(ctx, c, ws.ID); err != nil {
217+ stopSpinner()
218+ return fmt.Errorf("agent not ready: %w", err)
219+ }
220+
221+ // Force-update agent models so MCP tools are loaded.
222+ if err := c.UpdateAgent(ctx, ws.ID); err != nil {
223+ slog.Warn("Failed to update agent", "error", err)
224+ }
225+
226+ defer stopSpinner()
227+
228+ sess, err := resolveSession(ctx, c, ws.ID, continueSessionID, useLast)
229+ if err != nil {
230+ return fmt.Errorf("failed to resolve session: %w", err)
231+ }
232+ if continueSessionID != "" || useLast {
233+ slog.Info("Continuing session for non-interactive run", "session_id", sess.ID)
234+ } else {
235+ slog.Info("Created session for non-interactive run", "session_id", sess.ID)
236+ }
237+
238+ events, err := c.SubscribeEvents(ctx, ws.ID)
239+ if err != nil {
240+ return fmt.Errorf("failed to subscribe to events: %w", err)
241+ }
242+
243+ if err := c.SendMessage(ctx, ws.ID, sess.ID, prompt); err != nil {
244+ return fmt.Errorf("failed to send message: %w", err)
245+ }
246+
247+ messageReadBytes := make(map[string]int)
248+ var printed bool
249+
250+ defer func() {
251+ if progress && stderrTTY {
252+ _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
253+ }
254+ _, _ = fmt.Fprintln(os.Stdout)
255+ }()
256+
257+ for {
258+ if progress && stderrTTY {
259+ _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
260+ }
261+
262+ select {
263+ case ev, ok := <-events:
264+ if !ok {
265+ stopSpinner()
266+ return nil
267+ }
268+
269+ switch e := ev.(type) {
270+ case pubsub.Event[proto.Message]:
271+ msg := e.Payload
272+ if msg.SessionID != sess.ID || msg.Role != proto.Assistant || len(msg.Parts) == 0 {
273+ continue
274+ }
275+ stopSpinner()
276+
277+ content := msg.Content().String()
278+ readBytes := messageReadBytes[msg.ID]
279+
280+ if len(content) < readBytes {
281+ slog.Error("Non-interactive: message content shorter than read bytes",
282+ "message_length", len(content), "read_bytes", readBytes)
283+ return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(content), readBytes)
284+ }
285+
286+ part := content[readBytes:]
287+ if readBytes == 0 {
288+ part = strings.TrimLeft(part, " \t")
289+ }
290+ if printed || strings.TrimSpace(part) != "" {
291+ printed = true
292+ fmt.Fprint(os.Stdout, part)
293+ }
294+ messageReadBytes[msg.ID] = len(content)
295+
296+ if msg.IsFinished() {
297+ return nil
298+ }
299+
300+ case pubsub.Event[proto.AgentEvent]:
301+ if e.Payload.Error != nil {
302+ stopSpinner()
303+ return fmt.Errorf("agent error: %w", e.Payload.Error)
304+ }
305+ }
306+
307+ case <-ctx.Done():
308+ stopSpinner()
309+ return ctx.Err()
310+ }
311+ }
312+}
313+
314+// waitForAgent polls GetAgentInfo until the agent is ready, with a
315+// timeout.
316+func waitForAgent(ctx context.Context, c *client.Client, wsID string) error {
317+ timeout := time.After(30 * time.Second)
318+ for {
319+ info, err := c.GetAgentInfo(ctx, wsID)
320+ if err == nil && info.IsReady {
321+ return nil
322+ }
323+ select {
324+ case <-timeout:
325+ if err != nil {
326+ return fmt.Errorf("timeout waiting for agent: %w", err)
327+ }
328+ return fmt.Errorf("timeout waiting for agent readiness")
329+ case <-ctx.Done():
330+ return ctx.Err()
331+ case <-time.After(200 * time.Millisecond):
332+ }
333+ }
334+}
335+
336+// overrideModels resolves model strings and updates the workspace
337+// configuration via the server.
338+func overrideModels(
339+ ctx context.Context,
340+ c *client.Client,
341+ ws *proto.Workspace,
342+ largeModel, smallModel string,
343+) error {
344+ cfg, err := c.GetConfig(ctx, ws.ID)
345+ if err != nil {
346+ return fmt.Errorf("failed to get config: %w", err)
347+ }
348+
349+ providers := cfg.Providers.Copy()
350+
351+ largeMatches, smallMatches := findModelMatches(providers, largeModel, smallModel)
352+
353+ var largeProviderID string
354+
355+ if largeModel != "" {
356+ found, err := validateModelMatches(largeMatches, largeModel, "large")
357+ if err != nil {
358+ return err
359+ }
360+ largeProviderID = found.provider
361+ slog.Info("Overriding large model", "provider", found.provider, "model", found.modelID)
362+ if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeLarge, config.SelectedModel{
363+ Provider: found.provider,
364+ Model: found.modelID,
365+ }); err != nil {
366+ return fmt.Errorf("failed to set large model: %w", err)
367+ }
368+ }
369+
370+ switch {
371+ case smallModel != "":
372+ found, err := validateModelMatches(smallMatches, smallModel, "small")
373+ if err != nil {
374+ return err
375+ }
376+ slog.Info("Overriding small model", "provider", found.provider, "model", found.modelID)
377+ if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeSmall, config.SelectedModel{
378+ Provider: found.provider,
379+ Model: found.modelID,
380+ }); err != nil {
381+ return fmt.Errorf("failed to set small model: %w", err)
382+ }
383+
384+ case largeModel != "":
385+ sm, err := c.GetDefaultSmallModel(ctx, ws.ID, largeProviderID)
386+ if err != nil {
387+ slog.Warn("Failed to get default small model", "error", err)
388+ } else if sm != nil {
389+ if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeSmall, *sm); err != nil {
390+ return fmt.Errorf("failed to set small model: %w", err)
391+ }
392+ }
393+ }
394+
395+ return c.UpdateAgent(ctx, ws.ID)
396+}
397+
398+type modelMatch struct {
399+ provider string
400+ modelID string
401+}
402+
403+// findModelMatches searches providers for matching large/small model
404+// strings.
405+func findModelMatches(providers map[string]config.ProviderConfig, largeModel, smallModel string) ([]modelMatch, []modelMatch) {
406+ largeFilter, largeID := parseModelString(largeModel)
407+ smallFilter, smallID := parseModelString(smallModel)
408+
409+ var largeMatches, smallMatches []modelMatch
410+ for name, provider := range providers {
411+ if provider.Disable {
412+ continue
413+ }
414+ for _, m := range provider.Models {
415+ if matchesModel(largeID, largeFilter, m.ID, name) {
416+ largeMatches = append(largeMatches, modelMatch{provider: name, modelID: m.ID})
417+ }
418+ if matchesModel(smallID, smallFilter, m.ID, name) {
419+ smallMatches = append(smallMatches, modelMatch{provider: name, modelID: m.ID})
420+ }
421+ }
422+ }
423+ return largeMatches, smallMatches
424+}
425+
426+// parseModelString splits "provider/model" into (provider, model) or
427+// ("", model).
428+func parseModelString(s string) (string, string) {
429+ if s == "" {
430+ return "", ""
431+ }
432+ if idx := strings.Index(s, "/"); idx >= 0 {
433+ return s[:idx], s[idx+1:]
434+ }
435+ return "", s
436+}
437+
438+// matchesModel returns true if the model ID matches the filter
439+// criteria.
440+func matchesModel(wantID, wantProvider, modelID, providerName string) bool {
441+ if wantID == "" {
442+ return false
443+ }
444+ if wantProvider != "" && wantProvider != providerName {
445+ return false
446+ }
447+ return strings.EqualFold(modelID, wantID)
448+}
449+
450+// validateModelMatches ensures exactly one match exists.
451+func validateModelMatches(matches []modelMatch, modelID, label string) (modelMatch, error) {
452+ switch {
453+ case len(matches) == 0:
454+ return modelMatch{}, fmt.Errorf("%s model %q not found", label, modelID)
455+ case len(matches) > 1:
456+ names := make([]string, len(matches))
457+ for i, m := range matches {
458+ names[i] = m.provider
459+ }
460+ return modelMatch{}, fmt.Errorf(
461+ "%s model: model %q found in multiple providers: %s. Please specify provider using 'provider/model' format",
462+ label, modelID, strings.Join(names, ", "),
463+ )
464+ }
465+ return matches[0], nil
466+}
467+
468+// resolveSession returns the session to use for a non-interactive run.
469+// If continueSessionID is set it fetches that session; if useLast is set it
470+// returns the most recently updated top-level session; otherwise it creates a
471+// new one.
472+func resolveSession(ctx context.Context, c *client.Client, wsID, continueSessionID string, useLast bool) (*proto.Session, error) {
473+ switch {
474+ case continueSessionID != "":
475+ sess, err := c.GetSession(ctx, wsID, continueSessionID)
476+ if err != nil {
477+ return nil, fmt.Errorf("session not found: %s", continueSessionID)
478+ }
479+ if sess.ParentSessionID != "" {
480+ return nil, fmt.Errorf("cannot continue a child session: %s", continueSessionID)
481+ }
482+ return sess, nil
483+
484+ case useLast:
485+ sessions, err := c.ListSessions(ctx, wsID)
486+ if err != nil || len(sessions) == 0 {
487+ return nil, fmt.Errorf("no sessions found to continue")
488+ }
489+ last := sessions[0]
490+ for _, s := range sessions[1:] {
491+ if s.UpdatedAt > last.UpdatedAt && s.ParentSessionID == "" {
492+ last = s
493+ }
494+ }
495+ return &last, nil
496+
497+ default:
498+ return c.CreateSession(ctx, wsID, "non-interactive")
499+ }
500+}
501+
502+// resolveSessionByID resolves a session ID that may be a full UUID or a hash
503+// prefix returned by crush session list.
504+func resolveSessionByID(ctx context.Context, c *client.Client, wsID, id string) (*proto.Session, error) {
505+ if sess, err := c.GetSession(ctx, wsID, id); err == nil {
506+ return sess, nil
507+ }
508+
509+ sessions, err := c.ListSessions(ctx, wsID)
510+ if err != nil {
511+ return nil, err
512+ }
513+
514+ var matches []proto.Session
515+ for _, s := range sessions {
516+ hash := session.HashID(s.ID)
517+ if hash == id || strings.HasPrefix(hash, id) {
518+ matches = append(matches, s)
519+ }
520+ }
521+
522+ switch len(matches) {
523+ case 0:
524+ return nil, fmt.Errorf("session %q not found", id)
525+ case 1:
526+ return &matches[0], nil
527+ default:
528+ return nil, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
529+ }
530+}
internal/cmd/server.gonew file+99
@@ -0,0 +1,99 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "log/slog"
8+ "os"
9+ "os/signal"
10+ "path/filepath"
11+ "time"
12+
13+ "github.com/charmbracelet/crush/internal/config"
14+ crushlog "github.com/charmbracelet/crush/internal/log"
15+ "github.com/charmbracelet/crush/internal/server"
16+ "github.com/charmbracelet/x/term"
17+ "github.com/spf13/cobra"
18+)
19+
20+var serverHost string
21+
22+func init() {
23+ serverCmd.Flags().StringVarP(&serverHost, "host", "H", server.DefaultHost(), "Server host (TCP or Unix socket)")
24+ rootCmd.AddCommand(serverCmd)
25+}
26+
27+var serverCmd = &cobra.Command{
28+ Use: "server",
29+ Short: "Start the Crush server",
30+ RunE: func(cmd *cobra.Command, _ []string) error {
31+ dataDir, err := cmd.Flags().GetString("data-dir")
32+ if err != nil {
33+ return fmt.Errorf("failed to get data directory: %v", err)
34+ }
35+ debug, err := cmd.Flags().GetBool("debug")
36+ if err != nil {
37+ return fmt.Errorf("failed to get debug flag: %v", err)
38+ }
39+
40+ cfg, err := config.Load(config.GlobalWorkspaceDir(), dataDir, debug)
41+ if err != nil {
42+ return fmt.Errorf("failed to load configuration: %v", err)
43+ }
44+
45+ logFile := filepath.Join(config.GlobalCacheDir(), "server-"+safeNameRegexp.ReplaceAllString(serverHost, "_"), "crush.log")
46+
47+ if term.IsTerminal(os.Stderr.Fd()) {
48+ crushlog.Setup(logFile, debug, os.Stderr)
49+ } else {
50+ crushlog.Setup(logFile, debug)
51+ }
52+
53+ hostURL, err := server.ParseHostURL(serverHost)
54+ if err != nil {
55+ return fmt.Errorf("invalid server host: %v", err)
56+ }
57+
58+ srv := server.NewServer(cfg, hostURL.Scheme, hostURL.Host)
59+ srv.SetLogger(slog.Default())
60+ slog.Info("Starting Crush server...", "addr", serverHost)
61+
62+ errch := make(chan error, 1)
63+ sigch := make(chan os.Signal, 1)
64+ sigs := []os.Signal{os.Interrupt}
65+ sigs = append(sigs, addSignals(sigs)...)
66+ signal.Notify(sigch, sigs...)
67+
68+ go func() {
69+ errch <- srv.ListenAndServe()
70+ }()
71+
72+ select {
73+ case <-sigch:
74+ slog.Info("Received interrupt signal...")
75+ case err = <-errch:
76+ if err != nil && !errors.Is(err, server.ErrServerClosed) {
77+ _ = srv.Close()
78+ slog.Error("Server error", "error", err)
79+ return fmt.Errorf("server error: %v", err)
80+ }
81+ }
82+
83+ if errors.Is(err, server.ErrServerClosed) {
84+ return nil
85+ }
86+
87+ ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
88+ defer cancel()
89+
90+ slog.Info("Shutting down...")
91+
92+ if err := srv.Shutdown(ctx); err != nil {
93+ slog.Error("Failed to shutdown server", "error", err)
94+ return fmt.Errorf("failed to shutdown server: %v", err)
95+ }
96+
97+ return nil
98+ },
99+}
internal/cmd/server_other.gonew file+13
@@ -0,0 +1,13 @@
1+//go:build !windows
2+// +build !windows
3+
4+package cmd
5+
6+import (
7+ "os"
8+ "syscall"
9+)
10+
11+func addSignals(sigs []os.Signal) []os.Signal {
12+ return append(sigs, syscall.SIGTERM)
13+}
internal/cmd/server_windows.gonew file+10
@@ -0,0 +1,10 @@
1+//go:build windows
2+// +build windows
3+
4+package cmd
5+
6+import "os"
7+
8+func addSignals(sigs []os.Signal) []os.Signal {
9+ return sigs
10+}
internal/config/config.go+1−2
@@ -213,8 +213,7 @@ func (c Completions) Limits() (depth, items int) {
213}
213}
214
214
215type Permissions struct {
215type Permissions struct {
216− AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"` // Tools that don't require permission prompts
216+ AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"`
217− SkipRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode)
218}
217}
219
218
220type TrailerStyle string
219type TrailerStyle string
internal/config/load.go+28−7
@@ -22,7 +22,6 @@ import (
22 "github.com/charmbracelet/crush/internal/env"
22 "github.com/charmbracelet/crush/internal/env"
23 "github.com/charmbracelet/crush/internal/fsext"
23 "github.com/charmbracelet/crush/internal/fsext"
24 "github.com/charmbracelet/crush/internal/home"
24 "github.com/charmbracelet/crush/internal/home"
25− "github.com/charmbracelet/crush/internal/log"
26 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
25 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
27 "github.com/qjebbs/go-jsons"
26 "github.com/qjebbs/go-jsons"
28)
27)
@@ -52,12 +51,6 @@ func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
52 cfg.Options.Debug = true
51 cfg.Options.Debug = true
53 }
52 }
54
53
55− // Setup logs
56− log.Setup(
57− filepath.Join(cfg.Options.DataDirectory, "logs", fmt.Sprintf("%s.log", appName)),
58− cfg.Options.Debug,
59− )
60−
61 // Load workspace config last so it has highest priority.
54 // Load workspace config last so it has highest priority.
62 if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 {
55 if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 {
63 merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
56 merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
@@ -749,6 +742,25 @@ func GlobalConfig() string {
749 return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
742 return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
750}
743}
751
744
745+// GlobalCacheDir returns the path to the global cache directory for the
746+// application.
747+func GlobalCacheDir() string {
748+ if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
749+ return crushCache
750+ }
751+ if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
752+ return filepath.Join(xdgCacheHome, appName)
753+ }
754+ if runtime.GOOS == "windows" {
755+ localAppData := cmp.Or(
756+ os.Getenv("LOCALAPPDATA"),
757+ filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
758+ )
759+ return filepath.Join(localAppData, appName, "cache")
760+ }
761+ return filepath.Join(home.Dir(), ".cache", appName)
762+}
763+
752// GlobalConfigData returns the path to the main data directory for the application.
764// GlobalConfigData returns the path to the main data directory for the application.
753// this config is used when the app overrides configurations instead of updating the global config.
765// this config is used when the app overrides configurations instead of updating the global config.
754func GlobalConfigData() string {
766func GlobalConfigData() string {
@@ -773,6 +785,15 @@ func GlobalConfigData() string {
773 return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
785 return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
774}
786}
775
787
788+// GlobalWorkspaceDir returns the path to the global server workspace
789+// directory. This directory acts as a meta-workspace for the server
790+// process, giving it a real workingDir so that config loading, scoped
791+// writes, and provider resolution behave identically to project
792+// workspaces.
793+func GlobalWorkspaceDir() string {
794+ return filepath.Dir(GlobalConfigData())
795+}
796+
776func assignIfNil[T any](ptr **T, val T) {
797func assignIfNil[T any](ptr **T, val T) {
777 if *ptr == nil {
798 if *ptr == nil {
778 *ptr = &val
799 *ptr = &val
internal/config/resolve.go+14
@@ -14,6 +14,20 @@ type VariableResolver interface {
14 ResolveValue(value string) (string, error)
14 ResolveValue(value string) (string, error)
15}
15}
16
16
17+// identityResolver is a no-op resolver that returns values unchanged.
18+// Used in client mode where variable resolution is handled server-side.
19+type identityResolver struct{}
20+
21+func (identityResolver) ResolveValue(value string) (string, error) {
22+ return value, nil
23+}
24+
25+// IdentityResolver returns a VariableResolver that passes values through
26+// unchanged.
27+func IdentityResolver() VariableResolver {
28+ return identityResolver{}
29+}
30+
17type Shell interface {
31type Shell interface {
18 Exec(ctx context.Context, command string) (stdout, stderr string, err error)
32 Exec(ctx context.Context, command string) (stdout, stderr string, err error)
19}
33}
internal/config/scope.go+18
@@ -1,5 +1,7 @@
1package config
1package config
2
2
3+import "fmt"
4+
3// Scope determines which config file is targeted for read/write operations.
5// Scope determines which config file is targeted for read/write operations.
4type Scope int
6type Scope int
5
7
@@ -9,3 +11,19 @@ const (
9 // ScopeWorkspace targets the workspace config (.crush/crush.json).
11 // ScopeWorkspace targets the workspace config (.crush/crush.json).
10 ScopeWorkspace
12 ScopeWorkspace
11)
13)
14+
15+// String returns a human-readable label for the scope.
16+func (s Scope) String() string {
17+ switch s {
18+ case ScopeGlobal:
19+ return "global"
20+ case ScopeWorkspace:
21+ return "workspace"
22+ default:
23+ return fmt.Sprintf("Scope(%d)", int(s))
24+ }
25+}
26+
27+// ErrNoWorkspaceConfig is returned when a workspace-scoped write is
28+// attempted on a ConfigStore that has no workspace config path.
29+var ErrNoWorkspaceConfig = fmt.Errorf("no workspace config path configured")
internal/config/store.go+32−6
@@ -18,6 +18,13 @@ import (
18 "github.com/tidwall/sjson"
18 "github.com/tidwall/sjson"
19)
19)
20
20
21+// RuntimeOverrides holds per-session settings that are never persisted to
22+// disk. They are applied on top of the loaded Config and survive only for
23+// the lifetime of the process (or workspace).
24+type RuntimeOverrides struct {
25+ SkipPermissionRequests bool
26+}
27+
21// ConfigStore is the single entry point for all config access. It owns the
28// ConfigStore is the single entry point for all config access. It owns the
22// pure-data Config, runtime state (working directory, resolver, known
29// pure-data Config, runtime state (working directory, resolver, known
23// providers), and persistence to both global and workspace config files.
30// providers), and persistence to both global and workspace config files.
@@ -28,6 +35,7 @@ type ConfigStore struct {
28 globalDataPath string // ~/.local/share/crush/crush.json
35 globalDataPath string // ~/.local/share/crush/crush.json
29 workspacePath string // .crush/crush.json
36 workspacePath string // .crush/crush.json
30 knownProviders []catwalk.Provider
37 knownProviders []catwalk.Provider
38+ overrides RuntimeOverrides
31}
39}
32
40
33// Config returns the pure-data config struct (read-only after load).
41// Config returns the pure-data config struct (read-only after load).
@@ -63,20 +71,32 @@ func (s *ConfigStore) SetupAgents() {
63 s.config.SetupAgents()
71 s.config.SetupAgents()
64}
72}
65
73
74+// Overrides returns the runtime overrides for this store.
75+func (s *ConfigStore) Overrides() *RuntimeOverrides {
76+ return &s.overrides
77+}
78+
66// configPath returns the file path for the given scope.
79// configPath returns the file path for the given scope.
67−func (s *ConfigStore) configPath(scope Scope) string {
80+func (s *ConfigStore) configPath(scope Scope) (string, error) {
68 switch scope {
81 switch scope {
69 case ScopeWorkspace:
82 case ScopeWorkspace:
83+ if s.workspacePath == "" {
84+ return "", ErrNoWorkspaceConfig
85+ }
70− return s.workspacePath
86+ return s.workspacePath, nil
71 default:
87 default:
72− return s.globalDataPath
88+ return s.globalDataPath, nil
73 }
89 }
74}
90}
75
91
76// HasConfigField checks whether a key exists in the config file for the given
92// HasConfigField checks whether a key exists in the config file for the given
77// scope.
93// scope.
78func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
94func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
95+ path, err := s.configPath(scope)
96+ if err != nil {
97+ return false
98+ }
79− data, err := os.ReadFile(s.configPath(scope))
99+ data, err := os.ReadFile(path)
80 if err != nil {
100 if err != nil {
81 return false
101 return false
82 }
102 }
@@ -85,7 +105,10 @@ func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
85
105
86// SetConfigField sets a key/value pair in the config file for the given scope.
106// SetConfigField sets a key/value pair in the config file for the given scope.
87func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
107func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
88− path := s.configPath(scope)
108+ path, err := s.configPath(scope)
109+ if err != nil {
110+ return fmt.Errorf("%s: %w", key, err)
111+ }
89 data, err := os.ReadFile(path)
112 data, err := os.ReadFile(path)
90 if err != nil {
113 if err != nil {
91 if os.IsNotExist(err) {
114 if os.IsNotExist(err) {
@@ -110,7 +133,10 @@ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
110
133
111// RemoveConfigField removes a key from the config file for the given scope.
134// RemoveConfigField removes a key from the config file for the given scope.
112func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
135func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
113− path := s.configPath(scope)
136+ path, err := s.configPath(scope)
137+ if err != nil {
138+ return fmt.Errorf("%s: %w", key, err)
139+ }
114 data, err := os.ReadFile(path)
140 data, err := os.ReadFile(path)
115 if err != nil {
141 if err != nil {
116 return fmt.Errorf("failed to read config file: %w", err)
142 return fmt.Errorf("failed to read config file: %w", err)
internal/config/store_test.gonew file+152
@@ -0,0 +1,152 @@
1+package config
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestConfigStore_ConfigPath_GlobalAlwaysWorks(t *testing.T) {
13+ t.Parallel()
14+
15+ store := &ConfigStore{
16+ globalDataPath: "/some/global/crush.json",
17+ }
18+
19+ path, err := store.configPath(ScopeGlobal)
20+ require.NoError(t, err)
21+ require.Equal(t, "/some/global/crush.json", path)
22+}
23+
24+func TestConfigStore_ConfigPath_WorkspaceReturnsPath(t *testing.T) {
25+ t.Parallel()
26+
27+ store := &ConfigStore{
28+ workspacePath: "/some/workspace/.crush/crush.json",
29+ }
30+
31+ path, err := store.configPath(ScopeWorkspace)
32+ require.NoError(t, err)
33+ require.Equal(t, "/some/workspace/.crush/crush.json", path)
34+}
35+
36+func TestConfigStore_ConfigPath_WorkspaceErrorsWhenEmpty(t *testing.T) {
37+ t.Parallel()
38+
39+ store := &ConfigStore{
40+ globalDataPath: "/some/global/crush.json",
41+ workspacePath: "",
42+ }
43+
44+ _, err := store.configPath(ScopeWorkspace)
45+ require.Error(t, err)
46+ require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
47+}
48+
49+func TestConfigStore_SetConfigField_WorkspaceScopeGuard(t *testing.T) {
50+ t.Parallel()
51+
52+ store := &ConfigStore{
53+ config: &Config{},
54+ globalDataPath: filepath.Join(t.TempDir(), "global.json"),
55+ workspacePath: "",
56+ }
57+
58+ err := store.SetConfigField(ScopeWorkspace, "foo", "bar")
59+ require.Error(t, err)
60+ require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
61+}
62+
63+func TestConfigStore_SetConfigField_GlobalScopeAlwaysWorks(t *testing.T) {
64+ t.Parallel()
65+
66+ dir := t.TempDir()
67+ globalPath := filepath.Join(dir, "crush.json")
68+ store := &ConfigStore{
69+ config: &Config{},
70+ globalDataPath: globalPath,
71+ }
72+
73+ err := store.SetConfigField(ScopeGlobal, "foo", "bar")
74+ require.NoError(t, err)
75+
76+ data, err := os.ReadFile(globalPath)
77+ require.NoError(t, err)
78+ require.Contains(t, string(data), `"foo"`)
79+}
80+
81+func TestConfigStore_RemoveConfigField_WorkspaceScopeGuard(t *testing.T) {
82+ t.Parallel()
83+
84+ store := &ConfigStore{
85+ config: &Config{},
86+ globalDataPath: filepath.Join(t.TempDir(), "global.json"),
87+ workspacePath: "",
88+ }
89+
90+ err := store.RemoveConfigField(ScopeWorkspace, "foo")
91+ require.Error(t, err)
92+ require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
93+}
94+
95+func TestConfigStore_HasConfigField_WorkspaceScopeGuard(t *testing.T) {
96+ t.Parallel()
97+
98+ store := &ConfigStore{
99+ config: &Config{},
100+ globalDataPath: filepath.Join(t.TempDir(), "global.json"),
101+ workspacePath: "",
102+ }
103+
104+ has := store.HasConfigField(ScopeWorkspace, "foo")
105+ require.False(t, has)
106+}
107+
108+func TestConfigStore_RuntimeOverrides_Independent(t *testing.T) {
109+ t.Parallel()
110+
111+ store1 := &ConfigStore{config: &Config{}}
112+ store2 := &ConfigStore{config: &Config{}}
113+
114+ require.False(t, store1.Overrides().SkipPermissionRequests)
115+ require.False(t, store2.Overrides().SkipPermissionRequests)
116+
117+ store1.Overrides().SkipPermissionRequests = true
118+
119+ require.True(t, store1.Overrides().SkipPermissionRequests)
120+ require.False(t, store2.Overrides().SkipPermissionRequests)
121+}
122+
123+func TestConfigStore_RuntimeOverrides_MutableViaPointer(t *testing.T) {
124+ t.Parallel()
125+
126+ store := &ConfigStore{config: &Config{}}
127+ overrides := store.Overrides()
128+
129+ require.False(t, overrides.SkipPermissionRequests)
130+
131+ overrides.SkipPermissionRequests = true
132+ require.True(t, store.Overrides().SkipPermissionRequests)
133+}
134+
135+func TestGlobalWorkspaceDir(t *testing.T) {
136+ dir := t.TempDir()
137+ t.Setenv("CRUSH_GLOBAL_DATA", dir)
138+
139+ wsDir := GlobalWorkspaceDir()
140+ globalData := GlobalConfigData()
141+
142+ require.Equal(t, filepath.Dir(globalData), wsDir)
143+ require.Equal(t, dir, wsDir)
144+}
145+
146+func TestScope_String(t *testing.T) {
147+ t.Parallel()
148+
149+ require.Equal(t, "global", ScopeGlobal.String())
150+ require.Equal(t, "workspace", ScopeWorkspace.String())
151+ require.Contains(t, Scope(99).String(), "Scope(99)")
152+}
internal/log/log.go+20−4
@@ -2,6 +2,7 @@ package log
2
2
3import (
3import (
4 "fmt"
4 "fmt"
5+ "io"
5 "log/slog"
6 "log/slog"
6 "os"
7 "os"
7 "runtime/debug"
8 "runtime/debug"
@@ -10,6 +11,7 @@ import (
10 "time"
11 "time"
11
12
12 "github.com/charmbracelet/crush/internal/event"
13 "github.com/charmbracelet/crush/internal/event"
14+ "github.com/charmbracelet/x/term"
13 "gopkg.in/natefinch/lumberjack.v2"
15 "gopkg.in/natefinch/lumberjack.v2"
14)
16)
15
17
@@ -18,7 +20,7 @@ var (
18 initialized atomic.Bool
20 initialized atomic.Bool
19)
21)
20
22
21−func Setup(logFile string, debug bool) {
23+func Setup(logFile string, debug bool, ws ...io.Writer) {
22 initOnce.Do(func() {
24 initOnce.Do(func() {
23 logRotator := &lumberjack.Logger{
25 logRotator := &lumberjack.Logger{
24 Filename: logFile,
26 Filename: logFile,
@@ -33,12 +35,26 @@ func Setup(logFile string, debug bool) {
33 level = slog.LevelDebug
35 level = slog.LevelDebug
34 }
36 }
35
37
36− logger := slog.NewJSONHandler(logRotator, &slog.HandlerOptions{
38+ opts := &slog.HandlerOptions{
37 Level: level,
39 Level: level,
38 AddSource: true,
40 AddSource: true,
39− })
41+ }
42+
43+ var handlers []slog.Handler
44+ handlers = append(handlers, slog.NewJSONHandler(logRotator, opts))
45+
46+ for _, w := range ws {
47+ if w == nil {
48+ continue
49+ }
50+ if f, ok := w.(term.File); ok && term.IsTerminal(f.Fd()) {
51+ handlers = append(handlers, slog.NewTextHandler(w, opts))
52+ } else {
53+ handlers = append(handlers, slog.NewJSONHandler(w, opts))
54+ }
55+ }
40
56
41− slog.SetDefault(slog.New(logger))
57+ slog.SetDefault(slog.New(slog.NewMultiHandler(handlers...)))
42 initialized.Store(true)
58 initialized.Store(true)
43 })
59 })
44}
60}
internal/proto/agent.gonew file+75
@@ -0,0 +1,75 @@
1+package proto
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+)
7+
8+// AgentEventType represents the type of agent event.
9+type AgentEventType string
10+
11+const (
12+ AgentEventTypeError AgentEventType = "error"
13+ AgentEventTypeResponse AgentEventType = "response"
14+ AgentEventTypeSummarize AgentEventType = "summarize"
15+)
16+
17+// MarshalText implements the [encoding.TextMarshaler] interface.
18+func (t AgentEventType) MarshalText() ([]byte, error) {
19+ return []byte(t), nil
20+}
21+
22+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
23+func (t *AgentEventType) UnmarshalText(text []byte) error {
24+ *t = AgentEventType(text)
25+ return nil
26+}
27+
28+// AgentEvent represents an event emitted by the agent.
29+type AgentEvent struct {
30+ Type AgentEventType `json:"type"`
31+ Message Message `json:"message"`
32+ Error error `json:"error,omitempty"`
33+
34+ // When summarizing.
35+ SessionID string `json:"session_id,omitempty"`
36+ SessionTitle string `json:"session_title,omitempty"`
37+ Progress string `json:"progress,omitempty"`
38+ Done bool `json:"done,omitempty"`
39+}
40+
41+// MarshalJSON implements the [json.Marshaler] interface.
42+func (e AgentEvent) MarshalJSON() ([]byte, error) {
43+ type Alias AgentEvent
44+ return json.Marshal(&struct {
45+ Error string `json:"error,omitempty"`
46+ Alias
47+ }{
48+ Error: func() string {
49+ if e.Error != nil {
50+ return e.Error.Error()
51+ }
52+ return ""
53+ }(),
54+ Alias: (Alias)(e),
55+ })
56+}
57+
58+// UnmarshalJSON implements the [json.Unmarshaler] interface.
59+func (e *AgentEvent) UnmarshalJSON(data []byte) error {
60+ type Alias AgentEvent
61+ aux := &struct {
62+ Error string `json:"error,omitempty"`
63+ Alias
64+ }{
65+ Alias: (Alias)(*e),
66+ }
67+ if err := json.Unmarshal(data, &aux); err != nil {
68+ return err
69+ }
70+ *e = AgentEvent(aux.Alias)
71+ if aux.Error != "" {
72+ e.Error = errors.New(aux.Error)
73+ }
74+ return nil
75+}
internal/proto/history.gonew file+12
@@ -0,0 +1,12 @@
1+package proto
2+
3+// File represents a file tracked in session history.
4+type File struct {
5+ ID string `json:"id"`
6+ SessionID string `json:"session_id"`
7+ Path string `json:"path"`
8+ Content string `json:"content"`
9+ Version int64 `json:"version"`
10+ CreatedAt int64 `json:"created_at"`
11+ UpdatedAt int64 `json:"updated_at"`
12+}
internal/proto/mcp.gonew file+172
@@ -0,0 +1,172 @@
1+package proto
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "time"
8+)
9+
10+// MCPState represents the current state of an MCP client.
11+type MCPState int
12+
13+const (
14+ MCPStateDisabled MCPState = iota
15+ MCPStateStarting
16+ MCPStateConnected
17+ MCPStateError
18+)
19+
20+// MarshalText implements the [encoding.TextMarshaler] interface.
21+func (s MCPState) MarshalText() ([]byte, error) {
22+ return []byte(s.String()), nil
23+}
24+
25+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
26+func (s *MCPState) UnmarshalText(data []byte) error {
27+ switch string(data) {
28+ case "disabled":
29+ *s = MCPStateDisabled
30+ case "starting":
31+ *s = MCPStateStarting
32+ case "connected":
33+ *s = MCPStateConnected
34+ case "error":
35+ *s = MCPStateError
36+ default:
37+ return fmt.Errorf("unknown mcp state: %s", data)
38+ }
39+ return nil
40+}
41+
42+// String returns the string representation of the MCPState.
43+func (s MCPState) String() string {
44+ switch s {
45+ case MCPStateDisabled:
46+ return "disabled"
47+ case MCPStateStarting:
48+ return "starting"
49+ case MCPStateConnected:
50+ return "connected"
51+ case MCPStateError:
52+ return "error"
53+ default:
54+ return "unknown"
55+ }
56+}
57+
58+// MCPEventType represents the type of MCP event.
59+type MCPEventType string
60+
61+const (
62+ MCPEventStateChanged MCPEventType = "state_changed"
63+ MCPEventToolsListChanged MCPEventType = "tools_list_changed"
64+ MCPEventPromptsListChanged MCPEventType = "prompts_list_changed"
65+ MCPEventResourcesListChanged MCPEventType = "resources_list_changed"
66+)
67+
68+// MarshalText implements the [encoding.TextMarshaler] interface.
69+func (t MCPEventType) MarshalText() ([]byte, error) {
70+ return []byte(t), nil
71+}
72+
73+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
74+func (t *MCPEventType) UnmarshalText(data []byte) error {
75+ *t = MCPEventType(data)
76+ return nil
77+}
78+
79+// MCPEvent represents an event in the MCP system.
80+type MCPEvent struct {
81+ Type MCPEventType `json:"type"`
82+ Name string `json:"name"`
83+ State MCPState `json:"state"`
84+ Error error `json:"error,omitempty"`
85+ ToolCount int `json:"tool_count,omitempty"`
86+ PromptCount int `json:"prompt_count,omitempty"`
87+ ResourceCount int `json:"resource_count,omitempty"`
88+}
89+
90+// MarshalJSON implements the [json.Marshaler] interface.
91+func (e MCPEvent) MarshalJSON() ([]byte, error) {
92+ type Alias MCPEvent
93+ return json.Marshal(&struct {
94+ Error string `json:"error,omitempty"`
95+ Alias
96+ }{
97+ Error: func() string {
98+ if e.Error != nil {
99+ return e.Error.Error()
100+ }
101+ return ""
102+ }(),
103+ Alias: (Alias)(e),
104+ })
105+}
106+
107+// UnmarshalJSON implements the [json.Unmarshaler] interface.
108+func (e *MCPEvent) UnmarshalJSON(data []byte) error {
109+ type Alias MCPEvent
110+ aux := &struct {
111+ Error string `json:"error,omitempty"`
112+ Alias
113+ }{
114+ Alias: (Alias)(*e),
115+ }
116+ if err := json.Unmarshal(data, &aux); err != nil {
117+ return err
118+ }
119+ *e = MCPEvent(aux.Alias)
120+ if aux.Error != "" {
121+ e.Error = errors.New(aux.Error)
122+ }
123+ return nil
124+}
125+
126+// MCPClientInfo is the wire-format representation of an MCP client's
127+// state, suitable for JSON transport between server and client.
128+type MCPClientInfo struct {
129+ Name string `json:"name"`
130+ State MCPState `json:"state"`
131+ Error error `json:"error,omitempty"`
132+ ToolCount int `json:"tool_count,omitempty"`
133+ PromptCount int `json:"prompt_count,omitempty"`
134+ ResourceCount int `json:"resource_count,omitempty"`
135+ ConnectedAt time.Time `json:"connected_at"`
136+}
137+
138+// MarshalJSON implements the [json.Marshaler] interface.
139+func (i MCPClientInfo) MarshalJSON() ([]byte, error) {
140+ type Alias MCPClientInfo
141+ return json.Marshal(&struct {
142+ Error string `json:"error,omitempty"`
143+ Alias
144+ }{
145+ Error: func() string {
146+ if i.Error != nil {
147+ return i.Error.Error()
148+ }
149+ return ""
150+ }(),
151+ Alias: (Alias)(i),
152+ })
153+}
154+
155+// UnmarshalJSON implements the [json.Unmarshaler] interface.
156+func (i *MCPClientInfo) UnmarshalJSON(data []byte) error {
157+ type Alias MCPClientInfo
158+ aux := &struct {
159+ Error string `json:"error,omitempty"`
160+ Alias
161+ }{
162+ Alias: (Alias)(*i),
163+ }
164+ if err := json.Unmarshal(data, &aux); err != nil {
165+ return err
166+ }
167+ *i = MCPClientInfo(aux.Alias)
168+ if aux.Error != "" {
169+ i.Error = errors.New(aux.Error)
170+ }
171+ return nil
172+}
internal/proto/message.gonew file+653
@@ -0,0 +1,653 @@
1+package proto
2+
3+import (
4+ "encoding/base64"
5+ "encoding/json"
6+ "fmt"
7+ "slices"
8+ "time"
9+
10+ "charm.land/catwalk/pkg/catwalk"
11+)
12+
13+// CreateMessageParams represents parameters for creating a message.
14+type CreateMessageParams struct {
15+ Role MessageRole `json:"role"`
16+ Parts []ContentPart `json:"parts"`
17+ Model string `json:"model"`
18+ Provider string `json:"provider,omitempty"`
19+}
20+
21+// Message represents a message in the proto layer.
22+type Message struct {
23+ ID string `json:"id"`
24+ Role MessageRole `json:"role"`
25+ SessionID string `json:"session_id"`
26+ Parts []ContentPart `json:"parts"`
27+ Model string `json:"model"`
28+ Provider string `json:"provider"`
29+ CreatedAt int64 `json:"created_at"`
30+ UpdatedAt int64 `json:"updated_at"`
31+}
32+
33+// MessageRole represents the role of a message sender.
34+type MessageRole string
35+
36+const (
37+ Assistant MessageRole = "assistant"
38+ User MessageRole = "user"
39+ System MessageRole = "system"
40+ Tool MessageRole = "tool"
41+)
42+
43+// MarshalText implements the [encoding.TextMarshaler] interface.
44+func (r MessageRole) MarshalText() ([]byte, error) {
45+ return []byte(r), nil
46+}
47+
48+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
49+func (r *MessageRole) UnmarshalText(data []byte) error {
50+ *r = MessageRole(data)
51+ return nil
52+}
53+
54+// FinishReason represents why a message generation finished.
55+type FinishReason string
56+
57+const (
58+ FinishReasonEndTurn FinishReason = "end_turn"
59+ FinishReasonMaxTokens FinishReason = "max_tokens"
60+ FinishReasonToolUse FinishReason = "tool_use"
61+ FinishReasonCanceled FinishReason = "canceled"
62+ FinishReasonError FinishReason = "error"
63+ FinishReasonPermissionDenied FinishReason = "permission_denied"
64+ FinishReasonUnknown FinishReason = "unknown"
65+)
66+
67+// MarshalText implements the [encoding.TextMarshaler] interface.
68+func (fr FinishReason) MarshalText() ([]byte, error) {
69+ return []byte(fr), nil
70+}
71+
72+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
73+func (fr *FinishReason) UnmarshalText(data []byte) error {
74+ *fr = FinishReason(data)
75+ return nil
76+}
77+
78+// ContentPart is a part of a message's content.
79+type ContentPart interface {
80+ isPart()
81+}
82+
83+// ReasoningContent represents the reasoning/thinking part of a message.
84+type ReasoningContent struct {
85+ Thinking string `json:"thinking"`
86+ Signature string `json:"signature"`
87+ StartedAt int64 `json:"started_at,omitempty"`
88+ FinishedAt int64 `json:"finished_at,omitempty"`
89+}
90+
91+// String returns the thinking content as a string.
92+func (tc ReasoningContent) String() string {
93+ return tc.Thinking
94+}
95+
96+func (ReasoningContent) isPart() {}
97+
98+// TextContent represents a text part of a message.
99+type TextContent struct {
100+ Text string `json:"text"`
101+}
102+
103+// String returns the text content as a string.
104+func (tc TextContent) String() string {
105+ return tc.Text
106+}
107+
108+func (TextContent) isPart() {}
109+
110+// ImageURLContent represents an image URL part of a message.
111+type ImageURLContent struct {
112+ URL string `json:"url"`
113+ Detail string `json:"detail,omitempty"`
114+}
115+
116+// String returns the image URL as a string.
117+func (iuc ImageURLContent) String() string {
118+ return iuc.URL
119+}
120+
121+func (ImageURLContent) isPart() {}
122+
123+// BinaryContent represents binary data in a message.
124+type BinaryContent struct {
125+ Path string
126+ MIMEType string
127+ Data []byte
128+}
129+
130+// String returns a base64-encoded string of the binary data.
131+func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
132+ base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
133+ if p == catwalk.InferenceProviderOpenAI {
134+ return "data:" + bc.MIMEType + ";base64," + base64Encoded
135+ }
136+ return base64Encoded
137+}
138+
139+func (BinaryContent) isPart() {}
140+
141+// ToolCall represents a tool call in a message.
142+type ToolCall struct {
143+ ID string `json:"id"`
144+ Name string `json:"name"`
145+ Input string `json:"input"`
146+ Type string `json:"type,omitempty"`
147+ Finished bool `json:"finished,omitempty"`
148+}
149+
150+func (ToolCall) isPart() {}
151+
152+// ToolResult represents the result of a tool call.
153+type ToolResult struct {
154+ ToolCallID string `json:"tool_call_id"`
155+ Name string `json:"name"`
156+ Content string `json:"content"`
157+ Metadata string `json:"metadata"`
158+ IsError bool `json:"is_error"`
159+}
160+
161+func (ToolResult) isPart() {}
162+
163+// Finish represents the end of a message generation.
164+type Finish struct {
165+ Reason FinishReason `json:"reason"`
166+ Time int64 `json:"time"`
167+ Message string `json:"message,omitempty"`
168+ Details string `json:"details,omitempty"`
169+}
170+
171+func (Finish) isPart() {}
172+
173+// MarshalJSON implements the [json.Marshaler] interface.
174+func (m Message) MarshalJSON() ([]byte, error) {
175+ parts, err := MarshalParts(m.Parts)
176+ if err != nil {
177+ return nil, err
178+ }
179+
180+ type Alias Message
181+ return json.Marshal(&struct {
182+ Parts json.RawMessage `json:"parts"`
183+ *Alias
184+ }{
185+ Parts: json.RawMessage(parts),
186+ Alias: (*Alias)(&m),
187+ })
188+}
189+
190+// UnmarshalJSON implements the [json.Unmarshaler] interface.
191+func (m *Message) UnmarshalJSON(data []byte) error {
192+ type Alias Message
193+ aux := &struct {
194+ Parts json.RawMessage `json:"parts"`
195+ *Alias
196+ }{
197+ Alias: (*Alias)(m),
198+ }
199+
200+ if err := json.Unmarshal(data, &aux); err != nil {
201+ return err
202+ }
203+
204+ parts, err := UnmarshalParts([]byte(aux.Parts))
205+ if err != nil {
206+ return err
207+ }
208+
209+ m.Parts = parts
210+ return nil
211+}
212+
213+// Content returns the first text content part.
214+func (m *Message) Content() TextContent {
215+ for _, part := range m.Parts {
216+ if c, ok := part.(TextContent); ok {
217+ return c
218+ }
219+ }
220+ return TextContent{}
221+}
222+
223+// ReasoningContent returns the first reasoning content part.
224+func (m *Message) ReasoningContent() ReasoningContent {
225+ for _, part := range m.Parts {
226+ if c, ok := part.(ReasoningContent); ok {
227+ return c
228+ }
229+ }
230+ return ReasoningContent{}
231+}
232+
233+// ImageURLContent returns all image URL content parts.
234+func (m *Message) ImageURLContent() []ImageURLContent {
235+ imageURLContents := make([]ImageURLContent, 0)
236+ for _, part := range m.Parts {
237+ if c, ok := part.(ImageURLContent); ok {
238+ imageURLContents = append(imageURLContents, c)
239+ }
240+ }
241+ return imageURLContents
242+}
243+
244+// BinaryContent returns all binary content parts.
245+func (m *Message) BinaryContent() []BinaryContent {
246+ binaryContents := make([]BinaryContent, 0)
247+ for _, part := range m.Parts {
248+ if c, ok := part.(BinaryContent); ok {
249+ binaryContents = append(binaryContents, c)
250+ }
251+ }
252+ return binaryContents
253+}
254+
255+// ToolCalls returns all tool call parts.
256+func (m *Message) ToolCalls() []ToolCall {
257+ toolCalls := make([]ToolCall, 0)
258+ for _, part := range m.Parts {
259+ if c, ok := part.(ToolCall); ok {
260+ toolCalls = append(toolCalls, c)
261+ }
262+ }
263+ return toolCalls
264+}
265+
266+// ToolResults returns all tool result parts.
267+func (m *Message) ToolResults() []ToolResult {
268+ toolResults := make([]ToolResult, 0)
269+ for _, part := range m.Parts {
270+ if c, ok := part.(ToolResult); ok {
271+ toolResults = append(toolResults, c)
272+ }
273+ }
274+ return toolResults
275+}
276+
277+// IsFinished returns true if the message has a finish part.
278+func (m *Message) IsFinished() bool {
279+ for _, part := range m.Parts {
280+ if _, ok := part.(Finish); ok {
281+ return true
282+ }
283+ }
284+ return false
285+}
286+
287+// FinishPart returns the finish part if present.
288+func (m *Message) FinishPart() *Finish {
289+ for _, part := range m.Parts {
290+ if c, ok := part.(Finish); ok {
291+ return &c
292+ }
293+ }
294+ return nil
295+}
296+
297+// FinishReason returns the finish reason if present.
298+func (m *Message) FinishReason() FinishReason {
299+ for _, part := range m.Parts {
300+ if c, ok := part.(Finish); ok {
301+ return c.Reason
302+ }
303+ }
304+ return ""
305+}
306+
307+// IsThinking returns true if the message is currently in a thinking state.
308+func (m *Message) IsThinking() bool {
309+ return m.ReasoningContent().Thinking != "" && m.Content().Text == "" && !m.IsFinished()
310+}
311+
312+// AppendContent appends text to the text content part.
313+func (m *Message) AppendContent(delta string) {
314+ found := false
315+ for i, part := range m.Parts {
316+ if c, ok := part.(TextContent); ok {
317+ m.Parts[i] = TextContent{Text: c.Text + delta}
318+ found = true
319+ }
320+ }
321+ if !found {
322+ m.Parts = append(m.Parts, TextContent{Text: delta})
323+ }
324+}
325+
326+// AppendReasoningContent appends text to the reasoning content part.
327+func (m *Message) AppendReasoningContent(delta string) {
328+ found := false
329+ for i, part := range m.Parts {
330+ if c, ok := part.(ReasoningContent); ok {
331+ m.Parts[i] = ReasoningContent{
332+ Thinking: c.Thinking + delta,
333+ Signature: c.Signature,
334+ StartedAt: c.StartedAt,
335+ FinishedAt: c.FinishedAt,
336+ }
337+ found = true
338+ }
339+ }
340+ if !found {
341+ m.Parts = append(m.Parts, ReasoningContent{
342+ Thinking: delta,
343+ StartedAt: time.Now().Unix(),
344+ })
345+ }
346+}
347+
348+// AppendReasoningSignature appends a signature to the reasoning content part.
349+func (m *Message) AppendReasoningSignature(signature string) {
350+ for i, part := range m.Parts {
351+ if c, ok := part.(ReasoningContent); ok {
352+ m.Parts[i] = ReasoningContent{
353+ Thinking: c.Thinking,
354+ Signature: c.Signature + signature,
355+ StartedAt: c.StartedAt,
356+ FinishedAt: c.FinishedAt,
357+ }
358+ return
359+ }
360+ }
361+ m.Parts = append(m.Parts, ReasoningContent{Signature: signature})
362+}
363+
364+// FinishThinking marks the reasoning content as finished.
365+func (m *Message) FinishThinking() {
366+ for i, part := range m.Parts {
367+ if c, ok := part.(ReasoningContent); ok {
368+ if c.FinishedAt == 0 {
369+ m.Parts[i] = ReasoningContent{
370+ Thinking: c.Thinking,
371+ Signature: c.Signature,
372+ StartedAt: c.StartedAt,
373+ FinishedAt: time.Now().Unix(),
374+ }
375+ }
376+ return
377+ }
378+ }
379+}
380+
381+// ThinkingDuration returns the duration of the thinking phase.
382+func (m *Message) ThinkingDuration() time.Duration {
383+ reasoning := m.ReasoningContent()
384+ if reasoning.StartedAt == 0 {
385+ return 0
386+ }
387+
388+ endTime := reasoning.FinishedAt
389+ if endTime == 0 {
390+ endTime = time.Now().Unix()
391+ }
392+
393+ return time.Duration(endTime-reasoning.StartedAt) * time.Second
394+}
395+
396+// FinishToolCall marks a tool call as finished.
397+func (m *Message) FinishToolCall(toolCallID string) {
398+ for i, part := range m.Parts {
399+ if c, ok := part.(ToolCall); ok {
400+ if c.ID == toolCallID {
401+ m.Parts[i] = ToolCall{
402+ ID: c.ID,
403+ Name: c.Name,
404+ Input: c.Input,
405+ Type: c.Type,
406+ Finished: true,
407+ }
408+ return
409+ }
410+ }
411+ }
412+}
413+
414+// AppendToolCallInput appends input to a tool call.
415+func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) {
416+ for i, part := range m.Parts {
417+ if c, ok := part.(ToolCall); ok {
418+ if c.ID == toolCallID {
419+ m.Parts[i] = ToolCall{
420+ ID: c.ID,
421+ Name: c.Name,
422+ Input: c.Input + inputDelta,
423+ Type: c.Type,
424+ Finished: c.Finished,
425+ }
426+ return
427+ }
428+ }
429+ }
430+}
431+
432+// AddToolCall adds or updates a tool call.
433+func (m *Message) AddToolCall(tc ToolCall) {
434+ for i, part := range m.Parts {
435+ if c, ok := part.(ToolCall); ok {
436+ if c.ID == tc.ID {
437+ m.Parts[i] = tc
438+ return
439+ }
440+ }
441+ }
442+ m.Parts = append(m.Parts, tc)
443+}
444+
445+// SetToolCalls replaces all tool call parts.
446+func (m *Message) SetToolCalls(tc []ToolCall) {
447+ parts := make([]ContentPart, 0)
448+ for _, part := range m.Parts {
449+ if _, ok := part.(ToolCall); ok {
450+ continue
451+ }
452+ parts = append(parts, part)
453+ }
454+ m.Parts = parts
455+ for _, toolCall := range tc {
456+ m.Parts = append(m.Parts, toolCall)
457+ }
458+}
459+
460+// AddToolResult adds a tool result.
461+func (m *Message) AddToolResult(tr ToolResult) {
462+ m.Parts = append(m.Parts, tr)
463+}
464+
465+// SetToolResults adds multiple tool results.
466+func (m *Message) SetToolResults(tr []ToolResult) {
467+ for _, toolResult := range tr {
468+ m.Parts = append(m.Parts, toolResult)
469+ }
470+}
471+
472+// AddFinish adds a finish part to the message.
473+func (m *Message) AddFinish(reason FinishReason, message, details string) {
474+ for i, part := range m.Parts {
475+ if _, ok := part.(Finish); ok {
476+ m.Parts = slices.Delete(m.Parts, i, i+1)
477+ break
478+ }
479+ }
480+ m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix(), Message: message, Details: details})
481+}
482+
483+// AddImageURL adds an image URL part to the message.
484+func (m *Message) AddImageURL(url, detail string) {
485+ m.Parts = append(m.Parts, ImageURLContent{URL: url, Detail: detail})
486+}
487+
488+// AddBinary adds a binary content part to the message.
489+func (m *Message) AddBinary(mimeType string, data []byte) {
490+ m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
491+}
492+
493+type partType string
494+
495+const (
496+ reasoningType partType = "reasoning"
497+ textType partType = "text"
498+ imageURLType partType = "image_url"
499+ binaryType partType = "binary"
500+ toolCallType partType = "tool_call"
501+ toolResultType partType = "tool_result"
502+ finishType partType = "finish"
503+)
504+
505+type partWrapper struct {
506+ Type partType `json:"type"`
507+ Data ContentPart `json:"data"`
508+}
509+
510+// MarshalParts marshals content parts to JSON.
511+func MarshalParts(parts []ContentPart) ([]byte, error) {
512+ wrappedParts := make([]partWrapper, len(parts))
513+
514+ for i, part := range parts {
515+ var typ partType
516+
517+ switch part.(type) {
518+ case ReasoningContent:
519+ typ = reasoningType
520+ case TextContent:
521+ typ = textType
522+ case ImageURLContent:
523+ typ = imageURLType
524+ case BinaryContent:
525+ typ = binaryType
526+ case ToolCall:
527+ typ = toolCallType
528+ case ToolResult:
529+ typ = toolResultType
530+ case Finish:
531+ typ = finishType
532+ default:
533+ return nil, fmt.Errorf("unknown part type: %T", part)
534+ }
535+
536+ wrappedParts[i] = partWrapper{
537+ Type: typ,
538+ Data: part,
539+ }
540+ }
541+ return json.Marshal(wrappedParts)
542+}
543+
544+// UnmarshalParts unmarshals content parts from JSON.
545+func UnmarshalParts(data []byte) ([]ContentPart, error) {
546+ temp := []json.RawMessage{}
547+
548+ if err := json.Unmarshal(data, &temp); err != nil {
549+ return nil, err
550+ }
551+
552+ parts := make([]ContentPart, 0)
553+
554+ for _, rawPart := range temp {
555+ var wrapper struct {
556+ Type partType `json:"type"`
557+ Data json.RawMessage `json:"data"`
558+ }
559+
560+ if err := json.Unmarshal(rawPart, &wrapper); err != nil {
561+ return nil, err
562+ }
563+
564+ switch wrapper.Type {
565+ case reasoningType:
566+ part := ReasoningContent{}
567+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
568+ return nil, err
569+ }
570+ parts = append(parts, part)
571+ case textType:
572+ part := TextContent{}
573+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
574+ return nil, err
575+ }
576+ parts = append(parts, part)
577+ case imageURLType:
578+ part := ImageURLContent{}
579+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
580+ return nil, err
581+ }
582+ parts = append(parts, part)
583+ case binaryType:
584+ part := BinaryContent{}
585+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
586+ return nil, err
587+ }
588+ parts = append(parts, part)
589+ case toolCallType:
590+ part := ToolCall{}
591+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
592+ return nil, err
593+ }
594+ parts = append(parts, part)
595+ case toolResultType:
596+ part := ToolResult{}
597+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
598+ return nil, err
599+ }
600+ parts = append(parts, part)
601+ case finishType:
602+ part := Finish{}
603+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
604+ return nil, err
605+ }
606+ parts = append(parts, part)
607+ default:
608+ return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
609+ }
610+ }
611+
612+ return parts, nil
613+}
614+
615+// Attachment represents a file attachment.
616+type Attachment struct {
617+ FilePath string `json:"file_path"`
618+ FileName string `json:"file_name"`
619+ MimeType string `json:"mime_type"`
620+ Content []byte `json:"content"`
621+}
622+
623+// MarshalJSON implements the [json.Marshaler] interface.
624+func (a Attachment) MarshalJSON() ([]byte, error) {
625+ type Alias Attachment
626+ return json.Marshal(&struct {
627+ Content string `json:"content"`
628+ *Alias
629+ }{
630+ Content: base64.StdEncoding.EncodeToString(a.Content),
631+ Alias: (*Alias)(&a),
632+ })
633+}
634+
635+// UnmarshalJSON implements the [json.Unmarshaler] interface.
636+func (a *Attachment) UnmarshalJSON(data []byte) error {
637+ type Alias Attachment
638+ aux := &struct {
639+ Content string `json:"content"`
640+ *Alias
641+ }{
642+ Alias: (*Alias)(a),
643+ }
644+ if err := json.Unmarshal(data, &aux); err != nil {
645+ return err
646+ }
647+ content, err := base64.StdEncoding.DecodeString(aux.Content)
648+ if err != nil {
649+ return err
650+ }
651+ a.Content = content
652+ return nil
653+}
internal/proto/permission.gonew file+141
@@ -0,0 +1,141 @@
1+package proto
2+
3+import (
4+ "encoding/json"
5+)
6+
7+// CreatePermissionRequest represents a request to create a permission.
8+type CreatePermissionRequest struct {
9+ SessionID string `json:"session_id"`
10+ ToolCallID string `json:"tool_call_id"`
11+ ToolName string `json:"tool_name"`
12+ Description string `json:"description"`
13+ Action string `json:"action"`
14+ Params any `json:"params"`
15+ Path string `json:"path"`
16+}
17+
18+// PermissionNotification represents a notification about a permission change.
19+type PermissionNotification struct {
20+ ToolCallID string `json:"tool_call_id"`
21+ Granted bool `json:"granted"`
22+ Denied bool `json:"denied"`
23+}
24+
25+// PermissionRequest represents a pending permission request.
26+type PermissionRequest struct {
27+ ID string `json:"id"`
28+ SessionID string `json:"session_id"`
29+ ToolCallID string `json:"tool_call_id"`
30+ ToolName string `json:"tool_name"`
31+ Description string `json:"description"`
32+ Action string `json:"action"`
33+ Params any `json:"params"`
34+ Path string `json:"path"`
35+}
36+
37+// UnmarshalJSON implements the json.Unmarshaler interface. This is needed
38+// because the Params field is of type any, so we need to unmarshal it into
39+// its appropriate type based on the [PermissionRequest.ToolName].
40+func (p *PermissionRequest) UnmarshalJSON(data []byte) error {
41+ type Alias PermissionRequest
42+ aux := &struct {
43+ Params json.RawMessage `json:"params"`
44+ *Alias
45+ }{
46+ Alias: (*Alias)(p),
47+ }
48+ if err := json.Unmarshal(data, &aux); err != nil {
49+ return err
50+ }
51+
52+ params, err := unmarshalToolParams(p.ToolName, aux.Params)
53+ if err != nil {
54+ return err
55+ }
56+ p.Params = params
57+ return nil
58+}
59+
60+// UnmarshalJSON implements the json.Unmarshaler interface. This is needed
61+// because the Params field is of type any, so we need to unmarshal it into
62+// its appropriate type based on the [CreatePermissionRequest.ToolName].
63+func (p *CreatePermissionRequest) UnmarshalJSON(data []byte) error {
64+ type Alias CreatePermissionRequest
65+ aux := &struct {
66+ Params json.RawMessage `json:"params"`
67+ *Alias
68+ }{
69+ Alias: (*Alias)(p),
70+ }
71+ if err := json.Unmarshal(data, &aux); err != nil {
72+ return err
73+ }
74+
75+ params, err := unmarshalToolParams(p.ToolName, aux.Params)
76+ if err != nil {
77+ return err
78+ }
79+ p.Params = params
80+ return nil
81+}
82+
83+func unmarshalToolParams(toolName string, raw json.RawMessage) (any, error) {
84+ switch toolName {
85+ case BashToolName:
86+ var params BashPermissionsParams
87+ if err := json.Unmarshal(raw, &params); err != nil {
88+ return nil, err
89+ }
90+ return params, nil
91+ case DownloadToolName:
92+ var params DownloadPermissionsParams
93+ if err := json.Unmarshal(raw, &params); err != nil {
94+ return nil, err
95+ }
96+ return params, nil
97+ case EditToolName:
98+ var params EditPermissionsParams
99+ if err := json.Unmarshal(raw, &params); err != nil {
100+ return nil, err
101+ }
102+ return params, nil
103+ case WriteToolName:
104+ var params WritePermissionsParams
105+ if err := json.Unmarshal(raw, &params); err != nil {
106+ return nil, err
107+ }
108+ return params, nil
109+ case MultiEditToolName:
110+ var params MultiEditPermissionsParams
111+ if err := json.Unmarshal(raw, &params); err != nil {
112+ return nil, err
113+ }
114+ return params, nil
115+ case FetchToolName:
116+ var params FetchPermissionsParams
117+ if err := json.Unmarshal(raw, &params); err != nil {
118+ return nil, err
119+ }
120+ return params, nil
121+ case ViewToolName:
122+ var params ViewPermissionsParams
123+ if err := json.Unmarshal(raw, &params); err != nil {
124+ return nil, err
125+ }
126+ return params, nil
127+ case LSToolName:
128+ var params LSPermissionsParams
129+ if err := json.Unmarshal(raw, &params); err != nil {
130+ return nil, err
131+ }
132+ return params, nil
133+ default:
134+ // For unknown tools, keep the raw JSON as-is.
135+ var generic map[string]any
136+ if err := json.Unmarshal(raw, &generic); err != nil {
137+ return nil, err
138+ }
139+ return generic, nil
140+ }
141+}
internal/proto/proto.gonew file+200
@@ -0,0 +1,200 @@
1+package proto
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "time"
7+
8+ "charm.land/catwalk/pkg/catwalk"
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/lsp"
11+)
12+
13+// Workspace represents a running app.App workspace with its associated
14+// resources and state.
15+type Workspace struct {
16+ ID string `json:"id"`
17+ Path string `json:"path"`
18+ YOLO bool `json:"yolo,omitempty"`
19+ Debug bool `json:"debug,omitempty"`
20+ DataDir string `json:"data_dir,omitempty"`
21+ Version string `json:"version,omitempty"`
22+ Config *config.Config `json:"config,omitempty"`
23+ Env []string `json:"env,omitempty"`
24+}
25+
26+// Error represents an error response.
27+type Error struct {
28+ Message string `json:"message"`
29+}
30+
31+// AgentInfo represents information about the agent.
32+type AgentInfo struct {
33+ IsBusy bool `json:"is_busy"`
34+ IsReady bool `json:"is_ready"`
35+ Model catwalk.Model `json:"model"`
36+ ModelCfg config.SelectedModel `json:"model_cfg"`
37+}
38+
39+// IsZero checks if the AgentInfo is zero-valued.
40+func (a AgentInfo) IsZero() bool {
41+ return !a.IsBusy && !a.IsReady && a.Model.ID == ""
42+}
43+
44+// AgentMessage represents a message sent to the agent.
45+type AgentMessage struct {
46+ SessionID string `json:"session_id"`
47+ Prompt string `json:"prompt"`
48+ Attachments []Attachment `json:"attachments,omitempty"`
49+}
50+
51+// AgentSession represents a session with its busy status.
52+type AgentSession struct {
53+ Session
54+ IsBusy bool `json:"is_busy"`
55+}
56+
57+// IsZero checks if the AgentSession is zero-valued.
58+func (a AgentSession) IsZero() bool {
59+ return a == AgentSession{}
60+}
61+
62+// PermissionAction represents an action taken on a permission request.
63+type PermissionAction string
64+
65+const (
66+ PermissionAllow PermissionAction = "allow"
67+ PermissionAllowForSession PermissionAction = "allow_session"
68+ PermissionDeny PermissionAction = "deny"
69+)
70+
71+// MarshalText implements the [encoding.TextMarshaler] interface.
72+func (p PermissionAction) MarshalText() ([]byte, error) {
73+ return []byte(p), nil
74+}
75+
76+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
77+func (p *PermissionAction) UnmarshalText(text []byte) error {
78+ *p = PermissionAction(text)
79+ return nil
80+}
81+
82+// PermissionGrant represents a permission grant request.
83+type PermissionGrant struct {
84+ Permission PermissionRequest `json:"permission"`
85+ Action PermissionAction `json:"action"`
86+}
87+
88+// PermissionSkipRequest represents a request to skip permission prompts.
89+type PermissionSkipRequest struct {
90+ Skip bool `json:"skip"`
91+}
92+
93+// LSPEventType represents the type of LSP event.
94+type LSPEventType string
95+
96+const (
97+ LSPEventStateChanged LSPEventType = "state_changed"
98+ LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed"
99+)
100+
101+// MarshalText implements the [encoding.TextMarshaler] interface.
102+func (e LSPEventType) MarshalText() ([]byte, error) {
103+ return []byte(e), nil
104+}
105+
106+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
107+func (e *LSPEventType) UnmarshalText(data []byte) error {
108+ *e = LSPEventType(data)
109+ return nil
110+}
111+
112+// LSPEvent represents an event in the LSP system.
113+type LSPEvent struct {
114+ Type LSPEventType `json:"type"`
115+ Name string `json:"name"`
116+ State lsp.ServerState `json:"state"`
117+ Error error `json:"error,omitempty"`
118+ DiagnosticCount int `json:"diagnostic_count,omitempty"`
119+}
120+
121+// MarshalJSON implements the [json.Marshaler] interface.
122+func (e LSPEvent) MarshalJSON() ([]byte, error) {
123+ type Alias LSPEvent
124+ return json.Marshal(&struct {
125+ Error string `json:"error,omitempty"`
126+ Alias
127+ }{
128+ Error: func() string {
129+ if e.Error != nil {
130+ return e.Error.Error()
131+ }
132+ return ""
133+ }(),
134+ Alias: (Alias)(e),
135+ })
136+}
137+
138+// UnmarshalJSON implements the [json.Unmarshaler] interface.
139+func (e *LSPEvent) UnmarshalJSON(data []byte) error {
140+ type Alias LSPEvent
141+ aux := &struct {
142+ Error string `json:"error,omitempty"`
143+ Alias
144+ }{
145+ Alias: (Alias)(*e),
146+ }
147+ if err := json.Unmarshal(data, &aux); err != nil {
148+ return err
149+ }
150+ *e = LSPEvent(aux.Alias)
151+ if aux.Error != "" {
152+ e.Error = errors.New(aux.Error)
153+ }
154+ return nil
155+}
156+
157+// LSPClientInfo holds information about an LSP client's state.
158+type LSPClientInfo struct {
159+ Name string `json:"name"`
160+ State lsp.ServerState `json:"state"`
161+ Error error `json:"error,omitempty"`
162+ DiagnosticCount int `json:"diagnostic_count,omitempty"`
163+ ConnectedAt time.Time `json:"connected_at"`
164+}
165+
166+// MarshalJSON implements the [json.Marshaler] interface.
167+func (i LSPClientInfo) MarshalJSON() ([]byte, error) {
168+ type Alias LSPClientInfo
169+ return json.Marshal(&struct {
170+ Error string `json:"error,omitempty"`
171+ Alias
172+ }{
173+ Error: func() string {
174+ if i.Error != nil {
175+ return i.Error.Error()
176+ }
177+ return ""
178+ }(),
179+ Alias: (Alias)(i),
180+ })
181+}
182+
183+// UnmarshalJSON implements the [json.Unmarshaler] interface.
184+func (i *LSPClientInfo) UnmarshalJSON(data []byte) error {
185+ type Alias LSPClientInfo
186+ aux := &struct {
187+ Error string `json:"error,omitempty"`
188+ Alias
189+ }{
190+ Alias: (Alias)(*i),
191+ }
192+ if err := json.Unmarshal(data, &aux); err != nil {
193+ return err
194+ }
195+ *i = LSPClientInfo(aux.Alias)
196+ if aux.Error != "" {
197+ i.Error = errors.New(aux.Error)
198+ }
199+ return nil
200+}
internal/proto/requests.gonew file+92
@@ -0,0 +1,92 @@
1+package proto
2+
3+import "github.com/charmbracelet/crush/internal/config"
4+
5+// ConfigSetRequest represents a request to set a config field.
6+type ConfigSetRequest struct {
7+ Scope config.Scope `json:"scope"`
8+ Key string `json:"key"`
9+ Value any `json:"value"`
10+}
11+
12+// ConfigRemoveRequest represents a request to remove a config field.
13+type ConfigRemoveRequest struct {
14+ Scope config.Scope `json:"scope"`
15+ Key string `json:"key"`
16+}
17+
18+// ConfigModelRequest represents a request to update the preferred model.
19+type ConfigModelRequest struct {
20+ Scope config.Scope `json:"scope"`
21+ ModelType config.SelectedModelType `json:"model_type"`
22+ Model config.SelectedModel `json:"model"`
23+}
24+
25+// ConfigCompactRequest represents a request to set compact mode.
26+type ConfigCompactRequest struct {
27+ Scope config.Scope `json:"scope"`
28+ Enabled bool `json:"enabled"`
29+}
30+
31+// ConfigProviderKeyRequest represents a request to set a provider API key.
32+type ConfigProviderKeyRequest struct {
33+ Scope config.Scope `json:"scope"`
34+ ProviderID string `json:"provider_id"`
35+ APIKey any `json:"api_key"`
36+}
37+
38+// ConfigRefreshOAuthRequest represents a request to refresh an OAuth token.
39+type ConfigRefreshOAuthRequest struct {
40+ Scope config.Scope `json:"scope"`
41+ ProviderID string `json:"provider_id"`
42+}
43+
44+// ImportCopilotResponse represents the response from importing Copilot credentials.
45+type ImportCopilotResponse struct {
46+ Token any `json:"token"`
47+ Success bool `json:"success"`
48+}
49+
50+// ProjectNeedsInitResponse represents whether a project needs initialization.
51+type ProjectNeedsInitResponse struct {
52+ NeedsInit bool `json:"needs_init"`
53+}
54+
55+// ProjectInitPromptResponse represents the project initialization prompt.
56+type ProjectInitPromptResponse struct {
57+ Prompt string `json:"prompt"`
58+}
59+
60+// LSPStartRequest represents a request to start an LSP for a path.
61+type LSPStartRequest struct {
62+ Path string `json:"path"`
63+}
64+
65+// FileTrackerReadRequest represents a request to record a file read.
66+type FileTrackerReadRequest struct {
67+ SessionID string `json:"session_id"`
68+ Path string `json:"path"`
69+}
70+
71+// MCPNameRequest represents a request targeting a named MCP server.
72+type MCPNameRequest struct {
73+ Name string `json:"name"`
74+}
75+
76+// MCPReadResourceRequest represents a request to read an MCP resource.
77+type MCPReadResourceRequest struct {
78+ Name string `json:"name"`
79+ URI string `json:"uri"`
80+}
81+
82+// MCPGetPromptRequest represents a request to get an MCP prompt.
83+type MCPGetPromptRequest struct {
84+ ClientID string `json:"client_id"`
85+ PromptID string `json:"prompt_id"`
86+ Args map[string]string `json:"args"`
87+}
88+
89+// MCPGetPromptResponse represents the response from getting an MCP prompt.
90+type MCPGetPromptResponse struct {
91+ Prompt string `json:"prompt"`
92+}
internal/proto/server.gonew file+6
@@ -0,0 +1,6 @@
1+package proto
2+
3+// ServerControl represents a server control request.
4+type ServerControl struct {
5+ Command string `json:"command"`
6+}
internal/proto/session.gonew file+15
@@ -0,0 +1,15 @@
1+package proto
2+
3+// Session represents a session in the proto layer.
4+type Session struct {
5+ ID string `json:"id"`
6+ ParentSessionID string `json:"parent_session_id"`
7+ Title string `json:"title"`
8+ MessageCount int64 `json:"message_count"`
9+ PromptTokens int64 `json:"prompt_tokens"`
10+ CompletionTokens int64 `json:"completion_tokens"`
11+ SummaryMessageID string `json:"summary_message_id"`
12+ Cost float64 `json:"cost"`
13+ CreatedAt int64 `json:"created_at"`
14+ UpdatedAt int64 `json:"updated_at"`
15+}
internal/proto/tools.gonew file+250
@@ -0,0 +1,250 @@
1+package proto
2+
3+// ToolResponseType represents the type of tool response.
4+type ToolResponseType string
5+
6+const (
7+ ToolResponseTypeText ToolResponseType = "text"
8+ ToolResponseTypeImage ToolResponseType = "image"
9+)
10+
11+// ToolResponse represents a response from a tool.
12+type ToolResponse struct {
13+ Type ToolResponseType `json:"type"`
14+ Content string `json:"content"`
15+ Metadata string `json:"metadata,omitempty"`
16+ IsError bool `json:"is_error"`
17+}
18+
19+const BashToolName = "bash"
20+
21+// BashParams represents the parameters for the bash tool.
22+type BashParams struct {
23+ Command string `json:"command"`
24+ Timeout int `json:"timeout"`
25+}
26+
27+// BashPermissionsParams represents the permission parameters for the bash tool.
28+type BashPermissionsParams struct {
29+ Command string `json:"command"`
30+ Timeout int `json:"timeout"`
31+}
32+
33+// BashResponseMetadata represents the metadata for a bash tool response.
34+type BashResponseMetadata struct {
35+ StartTime int64 `json:"start_time"`
36+ EndTime int64 `json:"end_time"`
37+ Output string `json:"output"`
38+ WorkingDirectory string `json:"working_directory"`
39+}
40+
41+// DiagnosticsParams represents the parameters for the diagnostics tool.
42+type DiagnosticsParams struct {
43+ FilePath string `json:"file_path"`
44+}
45+
46+const DownloadToolName = "download"
47+
48+// DownloadParams represents the parameters for the download tool.
49+type DownloadParams struct {
50+ URL string `json:"url"`
51+ FilePath string `json:"file_path"`
52+ Timeout int `json:"timeout,omitempty"`
53+}
54+
55+// DownloadPermissionsParams represents the permission parameters for the download tool.
56+type DownloadPermissionsParams struct {
57+ URL string `json:"url"`
58+ FilePath string `json:"file_path"`
59+ Timeout int `json:"timeout,omitempty"`
60+}
61+
62+const EditToolName = "edit"
63+
64+// EditParams represents the parameters for the edit tool.
65+type EditParams struct {
66+ FilePath string `json:"file_path"`
67+ OldString string `json:"old_string"`
68+ NewString string `json:"new_string"`
69+ ReplaceAll bool `json:"replace_all,omitempty"`
70+}
71+
72+// EditPermissionsParams represents the permission parameters for the edit tool.
73+type EditPermissionsParams struct {
74+ FilePath string `json:"file_path"`
75+ OldContent string `json:"old_content,omitempty"`
76+ NewContent string `json:"new_content,omitempty"`
77+}
78+
79+// EditResponseMetadata represents the metadata for an edit tool response.
80+type EditResponseMetadata struct {
81+ Additions int `json:"additions"`
82+ Removals int `json:"removals"`
83+ OldContent string `json:"old_content,omitempty"`
84+ NewContent string `json:"new_content,omitempty"`
85+}
86+
87+const FetchToolName = "fetch"
88+
89+// FetchParams represents the parameters for the fetch tool.
90+type FetchParams struct {
91+ URL string `json:"url"`
92+ Format string `json:"format"`
93+ Timeout int `json:"timeout,omitempty"`
94+}
95+
96+// FetchPermissionsParams represents the permission parameters for the fetch tool.
97+type FetchPermissionsParams struct {
98+ URL string `json:"url"`
99+ Format string `json:"format"`
100+ Timeout int `json:"timeout,omitempty"`
101+}
102+
103+const GlobToolName = "glob"
104+
105+// GlobParams represents the parameters for the glob tool.
106+type GlobParams struct {
107+ Pattern string `json:"pattern"`
108+ Path string `json:"path"`
109+}
110+
111+// GlobResponseMetadata represents the metadata for a glob tool response.
112+type GlobResponseMetadata struct {
113+ NumberOfFiles int `json:"number_of_files"`
114+ Truncated bool `json:"truncated"`
115+}
116+
117+const GrepToolName = "grep"
118+
119+// GrepParams represents the parameters for the grep tool.
120+type GrepParams struct {
121+ Pattern string `json:"pattern"`
122+ Path string `json:"path"`
123+ Include string `json:"include"`
124+ LiteralText bool `json:"literal_text"`
125+}
126+
127+// GrepResponseMetadata represents the metadata for a grep tool response.
128+type GrepResponseMetadata struct {
129+ NumberOfMatches int `json:"number_of_matches"`
130+ Truncated bool `json:"truncated"`
131+}
132+
133+const LSToolName = "ls"
134+
135+// LSParams represents the parameters for the ls tool.
136+type LSParams struct {
137+ Path string `json:"path"`
138+ Ignore []string `json:"ignore"`
139+}
140+
141+// LSPermissionsParams represents the permission parameters for the ls tool.
142+type LSPermissionsParams struct {
143+ Path string `json:"path"`
144+ Ignore []string `json:"ignore"`
145+}
146+
147+// TreeNode represents a node in a directory tree.
148+type TreeNode struct {
149+ Name string `json:"name"`
150+ Path string `json:"path"`
151+ Type string `json:"type"`
152+ Children []*TreeNode `json:"children,omitempty"`
153+}
154+
155+// LSResponseMetadata represents the metadata for an ls tool response.
156+type LSResponseMetadata struct {
157+ NumberOfFiles int `json:"number_of_files"`
158+ Truncated bool `json:"truncated"`
159+}
160+
161+const MultiEditToolName = "multiedit"
162+
163+// MultiEditOperation represents a single edit operation in a multi-edit.
164+type MultiEditOperation struct {
165+ OldString string `json:"old_string"`
166+ NewString string `json:"new_string"`
167+ ReplaceAll bool `json:"replace_all,omitempty"`
168+}
169+
170+// MultiEditParams represents the parameters for the multi-edit tool.
171+type MultiEditParams struct {
172+ FilePath string `json:"file_path"`
173+ Edits []MultiEditOperation `json:"edits"`
174+}
175+
176+// MultiEditPermissionsParams represents the permission parameters for the multi-edit tool.
177+type MultiEditPermissionsParams struct {
178+ FilePath string `json:"file_path"`
179+ OldContent string `json:"old_content,omitempty"`
180+ NewContent string `json:"new_content,omitempty"`
181+}
182+
183+// MultiEditResponseMetadata represents the metadata for a multi-edit tool response.
184+type MultiEditResponseMetadata struct {
185+ Additions int `json:"additions"`
186+ Removals int `json:"removals"`
187+ OldContent string `json:"old_content,omitempty"`
188+ NewContent string `json:"new_content,omitempty"`
189+ EditsApplied int `json:"edits_applied"`
190+}
191+
192+const SourcegraphToolName = "sourcegraph"
193+
194+// SourcegraphParams represents the parameters for the sourcegraph tool.
195+type SourcegraphParams struct {
196+ Query string `json:"query"`
197+ Count int `json:"count,omitempty"`
198+ ContextWindow int `json:"context_window,omitempty"`
199+ Timeout int `json:"timeout,omitempty"`
200+}
201+
202+// SourcegraphResponseMetadata represents the metadata for a sourcegraph tool response.
203+type SourcegraphResponseMetadata struct {
204+ NumberOfMatches int `json:"number_of_matches"`
205+ Truncated bool `json:"truncated"`
206+}
207+
208+const ViewToolName = "view"
209+
210+// ViewParams represents the parameters for the view tool.
211+type ViewParams struct {
212+ FilePath string `json:"file_path"`
213+ Offset int `json:"offset"`
214+ Limit int `json:"limit"`
215+}
216+
217+// ViewPermissionsParams represents the permission parameters for the view tool.
218+type ViewPermissionsParams struct {
219+ FilePath string `json:"file_path"`
220+ Offset int `json:"offset"`
221+ Limit int `json:"limit"`
222+}
223+
224+// ViewResponseMetadata represents the metadata for a view tool response.
225+type ViewResponseMetadata struct {
226+ FilePath string `json:"file_path"`
227+ Content string `json:"content"`
228+}
229+
230+const WriteToolName = "write"
231+
232+// WriteParams represents the parameters for the write tool.
233+type WriteParams struct {
234+ FilePath string `json:"file_path"`
235+ Content string `json:"content"`
236+}
237+
238+// WritePermissionsParams represents the permission parameters for the write tool.
239+type WritePermissionsParams struct {
240+ FilePath string `json:"file_path"`
241+ OldContent string `json:"old_content,omitempty"`
242+ NewContent string `json:"new_content,omitempty"`
243+}
244+
245+// WriteResponseMetadata represents the metadata for a write tool response.
246+type WriteResponseMetadata struct {
247+ Diff string `json:"diff"`
248+ Additions int `json:"additions"`
249+ Removals int `json:"removals"`
250+}
internal/proto/version.gonew file+9
@@ -0,0 +1,9 @@
1+package proto
2+
3+// VersionInfo represents version information about the server.
4+type VersionInfo struct {
5+ Version string `json:"version"`
6+ Commit string `json:"commit"`
7+ GoVersion string `json:"go_version"`
8+ Platform string `json:"platform"`
9+}
internal/pubsub/events.go+31−5
@@ -1,6 +1,9 @@
1package pubsub
1package pubsub
2
2
3+import (
3−import "context"
4+ "context"
5+ "encoding/json"
6+)
4
7
5const (
8const (
6 CreatedEvent EventType = "created"
9 CreatedEvent EventType = "created"
@@ -8,20 +11,43 @@ const (
8 DeletedEvent EventType = "deleted"
11 DeletedEvent EventType = "deleted"
9)
12)
10
13
14+// PayloadType identifies the type of event payload for discriminated
15+// deserialization over JSON.
16+type PayloadType = string
17+
18+const (
19+ PayloadTypeLSPEvent PayloadType = "lsp_event"
20+ PayloadTypeMCPEvent PayloadType = "mcp_event"
21+ PayloadTypePermissionRequest PayloadType = "permission_request"
22+ PayloadTypePermissionNotification PayloadType = "permission_notification"
23+ PayloadTypeMessage PayloadType = "message"
24+ PayloadTypeSession PayloadType = "session"
25+ PayloadTypeFile PayloadType = "file"
26+ PayloadTypeAgentEvent PayloadType = "agent_event"
27+)
28+
29+// Payload wraps a discriminated JSON payload with a type tag.
30+type Payload struct {
31+ Type PayloadType `json:"type"`
32+ Payload json.RawMessage `json:"payload"`
33+}
34+
35+// Subscriber can subscribe to events of type T.
11type Subscriber[T any] interface {
36type Subscriber[T any] interface {
12 Subscribe(context.Context) <-chan Event[T]
37 Subscribe(context.Context) <-chan Event[T]
13}
38}
14
39
15type (
40type (
16− // EventType identifies the type of event
41+ // EventType identifies the type of event.
17 EventType string
42 EventType string
18
43
19− // Event represents an event in the lifecycle of a resource
44+ // Event represents an event in the lifecycle of a resource.
20 Event[T any] struct {
45 Event[T any] struct {
21− Type EventType
46+ Type EventType `json:"type"`
22− Payload T
47+ Payload T `json:"payload"`
23 }
48 }
24
49
50+ // Publisher can publish events of type T.
25 Publisher[T any] interface {
51 Publisher[T any] interface {
26 Publish(EventType, T)
52 Publish(EventType, T)
27 }
53 }
internal/server/config.gonew file+467
@@ -0,0 +1,467 @@
1+package server
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+
7+ "github.com/charmbracelet/crush/internal/proto"
8+)
9+
10+// handlePostWorkspaceConfigSet sets a configuration field.
11+//
12+// @Summary Set a config field
13+// @Tags config
14+// @Accept json
15+// @Param id path string true "Workspace ID"
16+// @Param request body proto.ConfigSetRequest true "Config set request"
17+// @Success 200
18+// @Failure 400 {object} proto.Error
19+// @Failure 404 {object} proto.Error
20+// @Failure 500 {object} proto.Error
21+// @Router /workspaces/{id}/config/set [post]
22+func (c *controllerV1) handlePostWorkspaceConfigSet(w http.ResponseWriter, r *http.Request) {
23+ id := r.PathValue("id")
24+
25+ var req proto.ConfigSetRequest
26+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
27+ c.server.logError(r, "Failed to decode request", "error", err)
28+ jsonError(w, http.StatusBadRequest, "failed to decode request")
29+ return
30+ }
31+
32+ if err := c.backend.SetConfigField(id, req.Scope, req.Key, req.Value); err != nil {
33+ c.handleError(w, r, err)
34+ return
35+ }
36+ w.WriteHeader(http.StatusOK)
37+}
38+
39+// handlePostWorkspaceConfigRemove removes a configuration field.
40+//
41+// @Summary Remove a config field
42+// @Tags config
43+// @Accept json
44+// @Param id path string true "Workspace ID"
45+// @Param request body proto.ConfigRemoveRequest true "Config remove request"
46+// @Success 200
47+// @Failure 400 {object} proto.Error
48+// @Failure 404 {object} proto.Error
49+// @Failure 500 {object} proto.Error
50+// @Router /workspaces/{id}/config/remove [post]
51+func (c *controllerV1) handlePostWorkspaceConfigRemove(w http.ResponseWriter, r *http.Request) {
52+ id := r.PathValue("id")
53+
54+ var req proto.ConfigRemoveRequest
55+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
56+ c.server.logError(r, "Failed to decode request", "error", err)
57+ jsonError(w, http.StatusBadRequest, "failed to decode request")
58+ return
59+ }
60+
61+ if err := c.backend.RemoveConfigField(id, req.Scope, req.Key); err != nil {
62+ c.handleError(w, r, err)
63+ return
64+ }
65+ w.WriteHeader(http.StatusOK)
66+}
67+
68+// handlePostWorkspaceConfigModel updates the preferred model.
69+//
70+// @Summary Set the preferred model
71+// @Tags config
72+// @Accept json
73+// @Param id path string true "Workspace ID"
74+// @Param request body proto.ConfigModelRequest true "Config model request"
75+// @Success 200
76+// @Failure 400 {object} proto.Error
77+// @Failure 404 {object} proto.Error
78+// @Failure 500 {object} proto.Error
79+// @Router /workspaces/{id}/config/model [post]
80+func (c *controllerV1) handlePostWorkspaceConfigModel(w http.ResponseWriter, r *http.Request) {
81+ id := r.PathValue("id")
82+
83+ var req proto.ConfigModelRequest
84+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
85+ c.server.logError(r, "Failed to decode request", "error", err)
86+ jsonError(w, http.StatusBadRequest, "failed to decode request")
87+ return
88+ }
89+
90+ if err := c.backend.UpdatePreferredModel(id, req.Scope, req.ModelType, req.Model); err != nil {
91+ c.handleError(w, r, err)
92+ return
93+ }
94+ w.WriteHeader(http.StatusOK)
95+}
96+
97+// handlePostWorkspaceConfigCompact sets compact mode.
98+//
99+// @Summary Set compact mode
100+// @Tags config
101+// @Accept json
102+// @Param id path string true "Workspace ID"
103+// @Param request body proto.ConfigCompactRequest true "Config compact request"
104+// @Success 200
105+// @Failure 400 {object} proto.Error
106+// @Failure 404 {object} proto.Error
107+// @Failure 500 {object} proto.Error
108+// @Router /workspaces/{id}/config/compact [post]
109+func (c *controllerV1) handlePostWorkspaceConfigCompact(w http.ResponseWriter, r *http.Request) {
110+ id := r.PathValue("id")
111+
112+ var req proto.ConfigCompactRequest
113+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
114+ c.server.logError(r, "Failed to decode request", "error", err)
115+ jsonError(w, http.StatusBadRequest, "failed to decode request")
116+ return
117+ }
118+
119+ if err := c.backend.SetCompactMode(id, req.Scope, req.Enabled); err != nil {
120+ c.handleError(w, r, err)
121+ return
122+ }
123+ w.WriteHeader(http.StatusOK)
124+}
125+
126+// handlePostWorkspaceConfigProviderKey sets a provider API key.
127+//
128+// @Summary Set provider API key
129+// @Tags config
130+// @Accept json
131+// @Param id path string true "Workspace ID"
132+// @Param request body proto.ConfigProviderKeyRequest true "Config provider key request"
133+// @Success 200
134+// @Failure 400 {object} proto.Error
135+// @Failure 404 {object} proto.Error
136+// @Failure 500 {object} proto.Error
137+// @Router /workspaces/{id}/config/provider-key [post]
138+func (c *controllerV1) handlePostWorkspaceConfigProviderKey(w http.ResponseWriter, r *http.Request) {
139+ id := r.PathValue("id")
140+
141+ var req proto.ConfigProviderKeyRequest
142+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
143+ c.server.logError(r, "Failed to decode request", "error", err)
144+ jsonError(w, http.StatusBadRequest, "failed to decode request")
145+ return
146+ }
147+
148+ if err := c.backend.SetProviderAPIKey(id, req.Scope, req.ProviderID, req.APIKey); err != nil {
149+ c.handleError(w, r, err)
150+ return
151+ }
152+ w.WriteHeader(http.StatusOK)
153+}
154+
155+// handlePostWorkspaceConfigImportCopilot imports Copilot credentials.
156+//
157+// @Summary Import Copilot credentials
158+// @Tags config
159+// @Produce json
160+// @Param id path string true "Workspace ID"
161+// @Success 200 {object} proto.ImportCopilotResponse
162+// @Failure 404 {object} proto.Error
163+// @Failure 500 {object} proto.Error
164+// @Router /workspaces/{id}/config/import-copilot [post]
165+func (c *controllerV1) handlePostWorkspaceConfigImportCopilot(w http.ResponseWriter, r *http.Request) {
166+ id := r.PathValue("id")
167+ token, ok, err := c.backend.ImportCopilot(id)
168+ if err != nil {
169+ c.handleError(w, r, err)
170+ return
171+ }
172+ jsonEncode(w, proto.ImportCopilotResponse{Token: token, Success: ok})
173+}
174+
175+// handlePostWorkspaceConfigRefreshOAuth refreshes an OAuth token for a provider.
176+//
177+// @Summary Refresh OAuth token
178+// @Tags config
179+// @Accept json
180+// @Param id path string true "Workspace ID"
181+// @Param request body proto.ConfigRefreshOAuthRequest true "Refresh OAuth request"
182+// @Success 200
183+// @Failure 400 {object} proto.Error
184+// @Failure 404 {object} proto.Error
185+// @Failure 500 {object} proto.Error
186+// @Router /workspaces/{id}/config/refresh-oauth [post]
187+func (c *controllerV1) handlePostWorkspaceConfigRefreshOAuth(w http.ResponseWriter, r *http.Request) {
188+ id := r.PathValue("id")
189+
190+ var req proto.ConfigRefreshOAuthRequest
191+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
192+ c.server.logError(r, "Failed to decode request", "error", err)
193+ jsonError(w, http.StatusBadRequest, "failed to decode request")
194+ return
195+ }
196+
197+ if err := c.backend.RefreshOAuthToken(r.Context(), id, req.Scope, req.ProviderID); err != nil {
198+ c.handleError(w, r, err)
199+ return
200+ }
201+ w.WriteHeader(http.StatusOK)
202+}
203+
204+// handleGetWorkspaceProjectNeedsInit reports whether a project needs initialization.
205+//
206+// @Summary Check if project needs initialization
207+// @Tags project
208+// @Produce json
209+// @Param id path string true "Workspace ID"
210+// @Success 200 {object} proto.ProjectNeedsInitResponse
211+// @Failure 404 {object} proto.Error
212+// @Failure 500 {object} proto.Error
213+// @Router /workspaces/{id}/project/needs-init [get]
214+func (c *controllerV1) handleGetWorkspaceProjectNeedsInit(w http.ResponseWriter, r *http.Request) {
215+ id := r.PathValue("id")
216+ needs, err := c.backend.ProjectNeedsInitialization(id)
217+ if err != nil {
218+ c.handleError(w, r, err)
219+ return
220+ }
221+ jsonEncode(w, proto.ProjectNeedsInitResponse{NeedsInit: needs})
222+}
223+
224+// handlePostWorkspaceProjectInit marks the project as initialized.
225+//
226+// @Summary Mark project as initialized
227+// @Tags project
228+// @Param id path string true "Workspace ID"
229+// @Success 200
230+// @Failure 404 {object} proto.Error
231+// @Failure 500 {object} proto.Error
232+// @Router /workspaces/{id}/project/init [post]
233+func (c *controllerV1) handlePostWorkspaceProjectInit(w http.ResponseWriter, r *http.Request) {
234+ id := r.PathValue("id")
235+ if err := c.backend.MarkProjectInitialized(id); err != nil {
236+ c.handleError(w, r, err)
237+ return
238+ }
239+ w.WriteHeader(http.StatusOK)
240+}
241+
242+// handleGetWorkspaceProjectInitPrompt returns the project initialization prompt.
243+//
244+// @Summary Get project initialization prompt
245+// @Tags project
246+// @Produce json
247+// @Param id path string true "Workspace ID"
248+// @Success 200 {object} proto.ProjectInitPromptResponse
249+// @Failure 404 {object} proto.Error
250+// @Failure 500 {object} proto.Error
251+// @Router /workspaces/{id}/project/init-prompt [get]
252+func (c *controllerV1) handleGetWorkspaceProjectInitPrompt(w http.ResponseWriter, r *http.Request) {
253+ id := r.PathValue("id")
254+ prompt, err := c.backend.InitializePrompt(id)
255+ if err != nil {
256+ c.handleError(w, r, err)
257+ return
258+ }
259+ jsonEncode(w, proto.ProjectInitPromptResponse{Prompt: prompt})
260+}
261+
262+// handlePostWorkspaceMCPEnableDocker enables the Docker MCP server.
263+//
264+// @Summary Enable Docker MCP
265+// @Tags mcp
266+// @Param id path string true "Workspace ID"
267+// @Success 200
268+// @Failure 404 {object} proto.Error
269+// @Failure 500 {object} proto.Error
270+// @Router /workspaces/{id}/mcp/docker/enable [post]
271+func (c *controllerV1) handlePostWorkspaceMCPEnableDocker(w http.ResponseWriter, r *http.Request) {
272+ id := r.PathValue("id")
273+ if err := c.backend.EnableDockerMCP(r.Context(), id); err != nil {
274+ c.handleError(w, r, err)
275+ return
276+ }
277+ w.WriteHeader(http.StatusOK)
278+}
279+
280+// handlePostWorkspaceMCPDisableDocker disables the Docker MCP server.
281+//
282+// @Summary Disable Docker MCP
283+// @Tags mcp
284+// @Param id path string true "Workspace ID"
285+// @Success 200
286+// @Failure 404 {object} proto.Error
287+// @Failure 500 {object} proto.Error
288+// @Router /workspaces/{id}/mcp/docker/disable [post]
289+func (c *controllerV1) handlePostWorkspaceMCPDisableDocker(w http.ResponseWriter, r *http.Request) {
290+ id := r.PathValue("id")
291+ if err := c.backend.DisableDockerMCP(id); err != nil {
292+ c.handleError(w, r, err)
293+ return
294+ }
295+ w.WriteHeader(http.StatusOK)
296+}
297+
298+// handlePostWorkspaceMCPRefreshTools refreshes tools for a named MCP server.
299+//
300+// @Summary Refresh MCP tools
301+// @Tags mcp
302+// @Accept json
303+// @Param id path string true "Workspace ID"
304+// @Param request body proto.MCPNameRequest true "MCP name request"
305+// @Success 200
306+// @Failure 400 {object} proto.Error
307+// @Failure 404 {object} proto.Error
308+// @Failure 500 {object} proto.Error
309+// @Router /workspaces/{id}/mcp/refresh-tools [post]
310+func (c *controllerV1) handlePostWorkspaceMCPRefreshTools(w http.ResponseWriter, r *http.Request) {
311+ id := r.PathValue("id")
312+
313+ var req proto.MCPNameRequest
314+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
315+ c.server.logError(r, "Failed to decode request", "error", err)
316+ jsonError(w, http.StatusBadRequest, "failed to decode request")
317+ return
318+ }
319+
320+ if err := c.backend.RefreshMCPTools(r.Context(), id, req.Name); err != nil {
321+ c.handleError(w, r, err)
322+ return
323+ }
324+ w.WriteHeader(http.StatusOK)
325+}
326+
327+// handlePostWorkspaceMCPReadResource reads a resource from an MCP server.
328+//
329+// @Summary Read MCP resource
330+// @Tags mcp
331+// @Accept json
332+// @Produce json
333+// @Param id path string true "Workspace ID"
334+// @Param request body proto.MCPReadResourceRequest true "MCP read resource request"
335+// @Success 200 {object} object
336+// @Failure 400 {object} proto.Error
337+// @Failure 404 {object} proto.Error
338+// @Failure 500 {object} proto.Error
339+// @Router /workspaces/{id}/mcp/read-resource [post]
340+func (c *controllerV1) handlePostWorkspaceMCPReadResource(w http.ResponseWriter, r *http.Request) {
341+ id := r.PathValue("id")
342+
343+ var req proto.MCPReadResourceRequest
344+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
345+ c.server.logError(r, "Failed to decode request", "error", err)
346+ jsonError(w, http.StatusBadRequest, "failed to decode request")
347+ return
348+ }
349+
350+ contents, err := c.backend.ReadMCPResource(r.Context(), id, req.Name, req.URI)
351+ if err != nil {
352+ c.handleError(w, r, err)
353+ return
354+ }
355+ jsonEncode(w, contents)
356+}
357+
358+// handlePostWorkspaceMCPGetPrompt retrieves a prompt from an MCP server.
359+//
360+// @Summary Get MCP prompt
361+// @Tags mcp
362+// @Accept json
363+// @Produce json
364+// @Param id path string true "Workspace ID"
365+// @Param request body proto.MCPGetPromptRequest true "MCP get prompt request"
366+// @Success 200 {object} proto.MCPGetPromptResponse
367+// @Failure 400 {object} proto.Error
368+// @Failure 404 {object} proto.Error
369+// @Failure 500 {object} proto.Error
370+// @Router /workspaces/{id}/mcp/get-prompt [post]
371+func (c *controllerV1) handlePostWorkspaceMCPGetPrompt(w http.ResponseWriter, r *http.Request) {
372+ id := r.PathValue("id")
373+
374+ var req proto.MCPGetPromptRequest
375+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
376+ c.server.logError(r, "Failed to decode request", "error", err)
377+ jsonError(w, http.StatusBadRequest, "failed to decode request")
378+ return
379+ }
380+
381+ prompt, err := c.backend.GetMCPPrompt(id, req.ClientID, req.PromptID, req.Args)
382+ if err != nil {
383+ c.handleError(w, r, err)
384+ return
385+ }
386+ jsonEncode(w, proto.MCPGetPromptResponse{Prompt: prompt})
387+}
388+
389+// handleGetWorkspaceMCPStates returns the state of all MCP clients.
390+//
391+// @Summary Get MCP client states
392+// @Tags mcp
393+// @Produce json
394+// @Param id path string true "Workspace ID"
395+// @Success 200 {object} map[string]proto.MCPClientInfo
396+// @Failure 404 {object} proto.Error
397+// @Failure 500 {object} proto.Error
398+// @Router /workspaces/{id}/mcp/states [get]
399+func (c *controllerV1) handleGetWorkspaceMCPStates(w http.ResponseWriter, r *http.Request) {
400+ id := r.PathValue("id")
401+ states := c.backend.MCPGetStates(id)
402+ result := make(map[string]proto.MCPClientInfo, len(states))
403+ for k, v := range states {
404+ result[k] = proto.MCPClientInfo{
405+ Name: v.Name,
406+ State: proto.MCPState(v.State),
407+ Error: v.Error,
408+ ToolCount: v.Counts.Tools,
409+ PromptCount: v.Counts.Prompts,
410+ ResourceCount: v.Counts.Resources,
411+ ConnectedAt: v.ConnectedAt,
412+ }
413+ }
414+ jsonEncode(w, result)
415+}
416+
417+// handlePostWorkspaceMCPRefreshPrompts refreshes prompts for a named MCP server.
418+//
419+// @Summary Refresh MCP prompts
420+// @Tags mcp
421+// @Accept json
422+// @Param id path string true "Workspace ID"
423+// @Param request body proto.MCPNameRequest true "MCP name request"
424+// @Success 200
425+// @Failure 400 {object} proto.Error
426+// @Failure 404 {object} proto.Error
427+// @Failure 500 {object} proto.Error
428+// @Router /workspaces/{id}/mcp/refresh-prompts [post]
429+func (c *controllerV1) handlePostWorkspaceMCPRefreshPrompts(w http.ResponseWriter, r *http.Request) {
430+ id := r.PathValue("id")
431+
432+ var req proto.MCPNameRequest
433+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
434+ c.server.logError(r, "Failed to decode request", "error", err)
435+ jsonError(w, http.StatusBadRequest, "failed to decode request")
436+ return
437+ }
438+
439+ c.backend.MCPRefreshPrompts(r.Context(), id, req.Name)
440+ w.WriteHeader(http.StatusOK)
441+}
442+
443+// handlePostWorkspaceMCPRefreshResources refreshes resources for a named MCP server.
444+//
445+// @Summary Refresh MCP resources
446+// @Tags mcp
447+// @Accept json
448+// @Param id path string true "Workspace ID"
449+// @Param request body proto.MCPNameRequest true "MCP name request"
450+// @Success 200
451+// @Failure 400 {object} proto.Error
452+// @Failure 404 {object} proto.Error
453+// @Failure 500 {object} proto.Error
454+// @Router /workspaces/{id}/mcp/refresh-resources [post]
455+func (c *controllerV1) handlePostWorkspaceMCPRefreshResources(w http.ResponseWriter, r *http.Request) {
456+ id := r.PathValue("id")
457+
458+ var req proto.MCPNameRequest
459+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
460+ c.server.logError(r, "Failed to decode request", "error", err)
461+ jsonError(w, http.StatusBadRequest, "failed to decode request")
462+ return
463+ }
464+
465+ c.backend.MCPRefreshResources(r.Context(), id, req.Name)
466+ w.WriteHeader(http.StatusOK)
467+}
internal/server/events.gonew file+214
@@ -0,0 +1,214 @@
1+package server
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "log/slog"
7+
8+ "github.com/charmbracelet/crush/internal/agent/notify"
9+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
10+ "github.com/charmbracelet/crush/internal/app"
11+ "github.com/charmbracelet/crush/internal/history"
12+ "github.com/charmbracelet/crush/internal/message"
13+ "github.com/charmbracelet/crush/internal/permission"
14+ "github.com/charmbracelet/crush/internal/proto"
15+ "github.com/charmbracelet/crush/internal/pubsub"
16+ "github.com/charmbracelet/crush/internal/session"
17+)
18+
19+// wrapEvent converts a raw tea.Msg (a pubsub.Event[T] from the app
20+// event fan-in) into a pubsub.Payload envelope with the correct
21+// PayloadType discriminator and a proto-typed inner payload that has
22+// proper JSON tags. Returns nil if the event type is unrecognized.
23+func wrapEvent(ev any) *pubsub.Payload {
24+ switch e := ev.(type) {
25+ case pubsub.Event[app.LSPEvent]:
26+ return envelope(pubsub.PayloadTypeLSPEvent, pubsub.Event[proto.LSPEvent]{
27+ Type: e.Type,
28+ Payload: proto.LSPEvent{
29+ Type: proto.LSPEventType(e.Payload.Type),
30+ Name: e.Payload.Name,
31+ State: e.Payload.State,
32+ Error: e.Payload.Error,
33+ DiagnosticCount: e.Payload.DiagnosticCount,
34+ },
35+ })
36+ case pubsub.Event[mcp.Event]:
37+ return envelope(pubsub.PayloadTypeMCPEvent, pubsub.Event[proto.MCPEvent]{
38+ Type: e.Type,
39+ Payload: proto.MCPEvent{
40+ Type: mcpEventTypeToProto(e.Payload.Type),
41+ Name: e.Payload.Name,
42+ State: proto.MCPState(e.Payload.State),
43+ Error: e.Payload.Error,
44+ ToolCount: e.Payload.Counts.Tools,
45+ },
46+ })
47+ case pubsub.Event[permission.PermissionRequest]:
48+ return envelope(pubsub.PayloadTypePermissionRequest, pubsub.Event[proto.PermissionRequest]{
49+ Type: e.Type,
50+ Payload: proto.PermissionRequest{
51+ ID: e.Payload.ID,
52+ SessionID: e.Payload.SessionID,
53+ ToolCallID: e.Payload.ToolCallID,
54+ ToolName: e.Payload.ToolName,
55+ Description: e.Payload.Description,
56+ Action: e.Payload.Action,
57+ Path: e.Payload.Path,
58+ Params: e.Payload.Params,
59+ },
60+ })
61+ case pubsub.Event[permission.PermissionNotification]:
62+ return envelope(pubsub.PayloadTypePermissionNotification, pubsub.Event[proto.PermissionNotification]{
63+ Type: e.Type,
64+ Payload: proto.PermissionNotification{
65+ ToolCallID: e.Payload.ToolCallID,
66+ Granted: e.Payload.Granted,
67+ Denied: e.Payload.Denied,
68+ },
69+ })
70+ case pubsub.Event[message.Message]:
71+ return envelope(pubsub.PayloadTypeMessage, pubsub.Event[proto.Message]{
72+ Type: e.Type,
73+ Payload: messageToProto(e.Payload),
74+ })
75+ case pubsub.Event[session.Session]:
76+ return envelope(pubsub.PayloadTypeSession, pubsub.Event[proto.Session]{
77+ Type: e.Type,
78+ Payload: sessionToProto(e.Payload),
79+ })
80+ case pubsub.Event[history.File]:
81+ return envelope(pubsub.PayloadTypeFile, pubsub.Event[proto.File]{
82+ Type: e.Type,
83+ Payload: fileToProto(e.Payload),
84+ })
85+ case pubsub.Event[notify.Notification]:
86+ return envelope(pubsub.PayloadTypeAgentEvent, pubsub.Event[proto.AgentEvent]{
87+ Type: e.Type,
88+ Payload: proto.AgentEvent{
89+ SessionID: e.Payload.SessionID,
90+ SessionTitle: e.Payload.SessionTitle,
91+ Type: proto.AgentEventType(e.Payload.Type),
92+ },
93+ })
94+ default:
95+ slog.Warn("Unrecognized event type for SSE wrapping", "type", fmt.Sprintf("%T", ev))
96+ return nil
97+ }
98+}
99+
100+// envelope marshals the inner event and wraps it in a pubsub.Payload.
101+func envelope(payloadType pubsub.PayloadType, inner any) *pubsub.Payload {
102+ raw, err := json.Marshal(inner)
103+ if err != nil {
104+ slog.Error("Failed to marshal event payload", "error", err)
105+ return nil
106+ }
107+ return &pubsub.Payload{
108+ Type: payloadType,
109+ Payload: raw,
110+ }
111+}
112+
113+func mcpEventTypeToProto(t mcp.EventType) proto.MCPEventType {
114+ switch t {
115+ case mcp.EventStateChanged:
116+ return proto.MCPEventStateChanged
117+ case mcp.EventToolsListChanged:
118+ return proto.MCPEventToolsListChanged
119+ case mcp.EventPromptsListChanged:
120+ return proto.MCPEventPromptsListChanged
121+ case mcp.EventResourcesListChanged:
122+ return proto.MCPEventResourcesListChanged
123+ default:
124+ return proto.MCPEventStateChanged
125+ }
126+}
127+
128+func sessionToProto(s session.Session) proto.Session {
129+ return proto.Session{
130+ ID: s.ID,
131+ ParentSessionID: s.ParentSessionID,
132+ Title: s.Title,
133+ SummaryMessageID: s.SummaryMessageID,
134+ MessageCount: s.MessageCount,
135+ PromptTokens: s.PromptTokens,
136+ CompletionTokens: s.CompletionTokens,
137+ Cost: s.Cost,
138+ CreatedAt: s.CreatedAt,
139+ UpdatedAt: s.UpdatedAt,
140+ }
141+}
142+
143+func fileToProto(f history.File) proto.File {
144+ return proto.File{
145+ ID: f.ID,
146+ SessionID: f.SessionID,
147+ Path: f.Path,
148+ Content: f.Content,
149+ Version: f.Version,
150+ CreatedAt: f.CreatedAt,
151+ UpdatedAt: f.UpdatedAt,
152+ }
153+}
154+
155+func messageToProto(m message.Message) proto.Message {
156+ msg := proto.Message{
157+ ID: m.ID,
158+ SessionID: m.SessionID,
159+ Role: proto.MessageRole(m.Role),
160+ Model: m.Model,
161+ Provider: m.Provider,
162+ CreatedAt: m.CreatedAt,
163+ UpdatedAt: m.UpdatedAt,
164+ }
165+
166+ for _, p := range m.Parts {
167+ switch v := p.(type) {
168+ case message.TextContent:
169+ msg.Parts = append(msg.Parts, proto.TextContent{Text: v.Text})
170+ case message.ReasoningContent:
171+ msg.Parts = append(msg.Parts, proto.ReasoningContent{
172+ Thinking: v.Thinking,
173+ Signature: v.Signature,
174+ StartedAt: v.StartedAt,
175+ FinishedAt: v.FinishedAt,
176+ })
177+ case message.ToolCall:
178+ msg.Parts = append(msg.Parts, proto.ToolCall{
179+ ID: v.ID,
180+ Name: v.Name,
181+ Input: v.Input,
182+ Finished: v.Finished,
183+ })
184+ case message.ToolResult:
185+ msg.Parts = append(msg.Parts, proto.ToolResult{
186+ ToolCallID: v.ToolCallID,
187+ Name: v.Name,
188+ Content: v.Content,
189+ IsError: v.IsError,
190+ })
191+ case message.Finish:
192+ msg.Parts = append(msg.Parts, proto.Finish{
193+ Reason: proto.FinishReason(v.Reason),
194+ Time: v.Time,
195+ Message: v.Message,
196+ Details: v.Details,
197+ })
198+ case message.ImageURLContent:
199+ msg.Parts = append(msg.Parts, proto.ImageURLContent{URL: v.URL, Detail: v.Detail})
200+ case message.BinaryContent:
201+ msg.Parts = append(msg.Parts, proto.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
202+ }
203+ }
204+
205+ return msg
206+}
207+
208+func messagesToProto(msgs []message.Message) []proto.Message {
209+ out := make([]proto.Message, len(msgs))
210+ for i, m := range msgs {
211+ out[i] = messageToProto(m)
212+ }
213+ return out
214+}
internal/server/logging.gonew file+51
@@ -0,0 +1,51 @@
1+package server
2+
3+import (
4+ "log/slog"
5+ "net/http"
6+ "time"
7+)
8+
9+func (s *Server) loggingHandler(next http.Handler) http.Handler {
10+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11+ if s.logger == nil {
12+ next.ServeHTTP(w, r)
13+ return
14+ }
15+
16+ start := time.Now()
17+ lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
18+ s.logger.Debug("HTTP request",
19+ slog.String("method", r.Method),
20+ slog.String("path", r.URL.Path),
21+ slog.String("remote_addr", r.RemoteAddr),
22+ slog.String("user_agent", r.UserAgent()),
23+ )
24+
25+ next.ServeHTTP(lrw, r)
26+ duration := time.Since(start)
27+
28+ s.logger.Debug("HTTP response",
29+ slog.String("method", r.Method),
30+ slog.String("path", r.URL.Path),
31+ slog.Int("status", lrw.statusCode),
32+ slog.Duration("duration", duration),
33+ slog.String("remote_addr", r.RemoteAddr),
34+ slog.String("user_agent", r.UserAgent()),
35+ )
36+ })
37+}
38+
39+type loggingResponseWriter struct {
40+ http.ResponseWriter
41+ statusCode int
42+}
43+
44+func (lrw *loggingResponseWriter) WriteHeader(code int) {
45+ lrw.statusCode = code
46+ lrw.ResponseWriter.WriteHeader(code)
47+}
48+
49+func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
50+ return lrw.ResponseWriter
51+}
internal/server/net_other.gonew file+11
@@ -0,0 +1,11 @@
1+//go:build !windows
2+// +build !windows
3+
4+package server
5+
6+import "net"
7+
8+func listen(network, address string) (net.Listener, error) {
9+ //nolint:noctx
10+ return net.Listen(network, address)
11+}
internal/server/net_windows.gonew file+24
@@ -0,0 +1,24 @@
1+//go:build windows
2+// +build windows
3+
4+package server
5+
6+import (
7+ "net"
8+
9+ "github.com/Microsoft/go-winio"
10+)
11+
12+func listen(network, address string) (net.Listener, error) {
13+ switch network {
14+ case "npipe":
15+ cfg := &winio.PipeConfig{
16+ MessageMode: true,
17+ InputBufferSize: 65536,
18+ OutputBufferSize: 65536,
19+ }
20+ return winio.ListenPipe(address, cfg)
21+ default:
22+ return net.Listen(network, address) //nolint:noctx
23+ }
24+}
internal/server/proto.gonew file+969
@@ -0,0 +1,969 @@
1+package server
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "fmt"
7+ "net/http"
8+
9+ "github.com/charmbracelet/crush/internal/backend"
10+ "github.com/charmbracelet/crush/internal/proto"
11+ "github.com/charmbracelet/crush/internal/session"
12+)
13+
14+type controllerV1 struct {
15+ backend *backend.Backend
16+ server *Server
17+}
18+
19+// handleGetHealth checks server health.
20+//
21+// @Summary Health check
22+// @Tags system
23+// @Success 200
24+// @Router /health [get]
25+func (c *controllerV1) handleGetHealth(w http.ResponseWriter, _ *http.Request) {
26+ w.WriteHeader(http.StatusOK)
27+}
28+
29+// handleGetVersion returns server version information.
30+//
31+// @Summary Get server version
32+// @Tags system
33+// @Produce json
34+// @Success 200 {object} proto.VersionInfo
35+// @Router /version [get]
36+func (c *controllerV1) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
37+ jsonEncode(w, c.backend.VersionInfo())
38+}
39+
40+// handlePostControl sends a control command to the server.
41+//
42+// @Summary Send server control command
43+// @Tags system
44+// @Accept json
45+// @Param request body proto.ServerControl true "Control command (e.g. shutdown)"
46+// @Success 200
47+// @Failure 400 {object} proto.Error
48+// @Router /control [post]
49+func (c *controllerV1) handlePostControl(w http.ResponseWriter, r *http.Request) {
50+ var req proto.ServerControl
51+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
52+ c.server.logError(r, "Failed to decode request", "error", err)
53+ jsonError(w, http.StatusBadRequest, "failed to decode request")
54+ return
55+ }
56+
57+ switch req.Command {
58+ case "shutdown":
59+ c.backend.Shutdown()
60+ default:
61+ c.server.logError(r, "Unknown command", "command", req.Command)
62+ jsonError(w, http.StatusBadRequest, "unknown command")
63+ return
64+ }
65+}
66+
67+// handleGetConfig returns global server configuration.
68+//
69+// @Summary Get server config
70+// @Tags system
71+// @Produce json
72+// @Success 200 {object} object
73+// @Router /config [get]
74+func (c *controllerV1) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
75+ jsonEncode(w, c.backend.Config())
76+}
77+
78+// handleGetWorkspaces lists all workspaces.
79+//
80+// @Summary List workspaces
81+// @Tags workspaces
82+// @Produce json
83+// @Success 200 {array} proto.Workspace
84+// @Router /workspaces [get]
85+func (c *controllerV1) handleGetWorkspaces(w http.ResponseWriter, _ *http.Request) {
86+ jsonEncode(w, c.backend.ListWorkspaces())
87+}
88+
89+// handleGetWorkspace returns a single workspace by ID.
90+//
91+// @Summary Get workspace
92+// @Tags workspaces
93+// @Produce json
94+// @Param id path string true "Workspace ID"
95+// @Success 200 {object} proto.Workspace
96+// @Failure 404 {object} proto.Error
97+// @Failure 500 {object} proto.Error
98+// @Router /workspaces/{id} [get]
99+func (c *controllerV1) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
100+ id := r.PathValue("id")
101+ ws, err := c.backend.GetWorkspaceProto(id)
102+ if err != nil {
103+ c.handleError(w, r, err)
104+ return
105+ }
106+ jsonEncode(w, ws)
107+}
108+
109+// handlePostWorkspaces creates a new workspace.
110+//
111+// @Summary Create workspace
112+// @Tags workspaces
113+// @Accept json
114+// @Produce json
115+// @Param request body proto.Workspace true "Workspace creation params"
116+// @Success 200 {object} proto.Workspace
117+// @Failure 400 {object} proto.Error
118+// @Failure 500 {object} proto.Error
119+// @Router /workspaces [post]
120+func (c *controllerV1) handlePostWorkspaces(w http.ResponseWriter, r *http.Request) {
121+ var args proto.Workspace
122+ if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
123+ c.server.logError(r, "Failed to decode request", "error", err)
124+ jsonError(w, http.StatusBadRequest, "failed to decode request")
125+ return
126+ }
127+
128+ _, result, err := c.backend.CreateWorkspace(args)
129+ if err != nil {
130+ c.handleError(w, r, err)
131+ return
132+ }
133+ jsonEncode(w, result)
134+}
135+
136+// handleDeleteWorkspaces deletes a workspace.
137+//
138+// @Summary Delete workspace
139+// @Tags workspaces
140+// @Param id path string true "Workspace ID"
141+// @Success 200
142+// @Failure 404 {object} proto.Error
143+// @Router /workspaces/{id} [delete]
144+func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Request) {
145+ id := r.PathValue("id")
146+ c.backend.DeleteWorkspace(id)
147+}
148+
149+// handleGetWorkspaceConfig returns workspace configuration.
150+//
151+// @Summary Get workspace config
152+// @Tags workspaces
153+// @Produce json
154+// @Param id path string true "Workspace ID"
155+// @Success 200 {object} object
156+// @Failure 404 {object} proto.Error
157+// @Failure 500 {object} proto.Error
158+// @Router /workspaces/{id}/config [get]
159+func (c *controllerV1) handleGetWorkspaceConfig(w http.ResponseWriter, r *http.Request) {
160+ id := r.PathValue("id")
161+ cfg, err := c.backend.GetWorkspaceConfig(id)
162+ if err != nil {
163+ c.handleError(w, r, err)
164+ return
165+ }
166+ jsonEncode(w, cfg)
167+}
168+
169+// handleGetWorkspaceProviders lists available providers for a workspace.
170+//
171+// @Summary Get workspace providers
172+// @Tags workspaces
173+// @Produce json
174+// @Param id path string true "Workspace ID"
175+// @Success 200 {object} object
176+// @Failure 404 {object} proto.Error
177+// @Failure 500 {object} proto.Error
178+// @Router /workspaces/{id}/providers [get]
179+func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *http.Request) {
180+ id := r.PathValue("id")
181+ providers, err := c.backend.GetWorkspaceProviders(id)
182+ if err != nil {
183+ c.handleError(w, r, err)
184+ return
185+ }
186+ jsonEncode(w, providers)
187+}
188+
189+// handleGetWorkspaceEvents streams workspace events as Server-Sent Events.
190+//
191+// @Summary Stream workspace events (SSE)
192+// @Tags workspaces
193+// @Produce text/event-stream
194+// @Param id path string true "Workspace ID"
195+// @Success 200
196+// @Failure 404 {object} proto.Error
197+// @Failure 500 {object} proto.Error
198+// @Router /workspaces/{id}/events [get]
199+func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) {
200+ flusher := http.NewResponseController(w)
201+ id := r.PathValue("id")
202+ events, err := c.backend.SubscribeEvents(id)
203+ if err != nil {
204+ c.handleError(w, r, err)
205+ return
206+ }
207+
208+ w.Header().Set("Content-Type", "text/event-stream")
209+ w.Header().Set("Cache-Control", "no-cache")
210+ w.Header().Set("Connection", "keep-alive")
211+
212+ for {
213+ select {
214+ case <-r.Context().Done():
215+ c.server.logDebug(r, "Stopping event stream")
216+ return
217+ case ev, ok := <-events:
218+ if !ok {
219+ return
220+ }
221+ c.server.logDebug(r, "Sending event", "event", fmt.Sprintf("%T %+v", ev, ev))
222+ wrapped := wrapEvent(ev)
223+ if wrapped == nil {
224+ continue
225+ }
226+ data, err := json.Marshal(wrapped)
227+ if err != nil {
228+ c.server.logError(r, "Failed to marshal event", "error", err)
229+ continue
230+ }
231+
232+ fmt.Fprintf(w, "data: %s\n\n", data)
233+ flusher.Flush()
234+ }
235+ }
236+}
237+
238+// handleGetWorkspaceLSPs lists LSP clients for a workspace.
239+//
240+// @Summary List LSP clients
241+// @Tags lsp
242+// @Produce json
243+// @Param id path string true "Workspace ID"
244+// @Success 200 {object} map[string]proto.LSPClientInfo
245+// @Failure 404 {object} proto.Error
246+// @Failure 500 {object} proto.Error
247+// @Router /workspaces/{id}/lsps [get]
248+func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) {
249+ id := r.PathValue("id")
250+ states, err := c.backend.GetLSPStates(id)
251+ if err != nil {
252+ c.handleError(w, r, err)
253+ return
254+ }
255+ result := make(map[string]proto.LSPClientInfo, len(states))
256+ for k, v := range states {
257+ result[k] = proto.LSPClientInfo{
258+ Name: v.Name,
259+ State: v.State,
260+ Error: v.Error,
261+ DiagnosticCount: v.DiagnosticCount,
262+ ConnectedAt: v.ConnectedAt,
263+ }
264+ }
265+ jsonEncode(w, result)
266+}
267+
268+// handleGetWorkspaceLSPDiagnostics returns diagnostics for an LSP client.
269+//
270+// @Summary Get LSP diagnostics
271+// @Tags lsp
272+// @Produce json
273+// @Param id path string true "Workspace ID"
274+// @Param lsp path string true "LSP client name"
275+// @Success 200 {object} object
276+// @Failure 404 {object} proto.Error
277+// @Failure 500 {object} proto.Error
278+// @Router /workspaces/{id}/lsps/{lsp}/diagnostics [get]
279+func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) {
280+ id := r.PathValue("id")
281+ lspName := r.PathValue("lsp")
282+ diagnostics, err := c.backend.GetLSPDiagnostics(id, lspName)
283+ if err != nil {
284+ c.handleError(w, r, err)
285+ return
286+ }
287+ jsonEncode(w, diagnostics)
288+}
289+
290+// handleGetWorkspaceSessions lists sessions for a workspace.
291+//
292+// @Summary List sessions
293+// @Tags sessions
294+// @Produce json
295+// @Param id path string true "Workspace ID"
296+// @Success 200 {array} proto.Session
297+// @Failure 404 {object} proto.Error
298+// @Failure 500 {object} proto.Error
299+// @Router /workspaces/{id}/sessions [get]
300+func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
301+ id := r.PathValue("id")
302+ sessions, err := c.backend.ListSessions(r.Context(), id)
303+ if err != nil {
304+ c.handleError(w, r, err)
305+ return
306+ }
307+ result := make([]proto.Session, len(sessions))
308+ for i, s := range sessions {
309+ result[i] = sessionToProto(s)
310+ }
311+ jsonEncode(w, result)
312+}
313+
314+// handlePostWorkspaceSessions creates a new session in a workspace.
315+//
316+// @Summary Create session
317+// @Tags sessions
318+// @Accept json
319+// @Produce json
320+// @Param id path string true "Workspace ID"
321+// @Param request body proto.Session true "Session creation params (title)"
322+// @Success 200 {object} proto.Session
323+// @Failure 400 {object} proto.Error
324+// @Failure 404 {object} proto.Error
325+// @Failure 500 {object} proto.Error
326+// @Router /workspaces/{id}/sessions [post]
327+func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
328+ id := r.PathValue("id")
329+
330+ var args session.Session
331+ if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
332+ c.server.logError(r, "Failed to decode request", "error", err)
333+ jsonError(w, http.StatusBadRequest, "failed to decode request")
334+ return
335+ }
336+
337+ sess, err := c.backend.CreateSession(r.Context(), id, args.Title)
338+ if err != nil {
339+ c.handleError(w, r, err)
340+ return
341+ }
342+ jsonEncode(w, sessionToProto(sess))
343+}
344+
345+// handleGetWorkspaceSession returns a single session.
346+//
347+// @Summary Get session
348+// @Tags sessions
349+// @Produce json
350+// @Param id path string true "Workspace ID"
351+// @Param sid path string true "Session ID"
352+// @Success 200 {object} proto.Session
353+// @Failure 404 {object} proto.Error
354+// @Failure 500 {object} proto.Error
355+// @Router /workspaces/{id}/sessions/{sid} [get]
356+func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) {
357+ id := r.PathValue("id")
358+ sid := r.PathValue("sid")
359+ sess, err := c.backend.GetSession(r.Context(), id, sid)
360+ if err != nil {
361+ c.handleError(w, r, err)
362+ return
363+ }
364+ jsonEncode(w, sessionToProto(sess))
365+}
366+
367+// handleGetWorkspaceSessionHistory returns the history for a session.
368+//
369+// @Summary Get session history
370+// @Tags sessions
371+// @Produce json
372+// @Param id path string true "Workspace ID"
373+// @Param sid path string true "Session ID"
374+// @Success 200 {array} proto.File
375+// @Failure 404 {object} proto.Error
376+// @Failure 500 {object} proto.Error
377+// @Router /workspaces/{id}/sessions/{sid}/history [get]
378+func (c *controllerV1) handleGetWorkspaceSessionHistory(w http.ResponseWriter, r *http.Request) {
379+ id := r.PathValue("id")
380+ sid := r.PathValue("sid")
381+ history, err := c.backend.ListSessionHistory(r.Context(), id, sid)
382+ if err != nil {
383+ c.handleError(w, r, err)
384+ return
385+ }
386+ jsonEncode(w, history)
387+}
388+
389+// handleGetWorkspaceSessionMessages returns all messages for a session.
390+//
391+// @Summary Get session messages
392+// @Tags sessions
393+// @Produce json
394+// @Param id path string true "Workspace ID"
395+// @Param sid path string true "Session ID"
396+// @Success 200 {array} proto.Message
397+// @Failure 404 {object} proto.Error
398+// @Failure 500 {object} proto.Error
399+// @Router /workspaces/{id}/sessions/{sid}/messages [get]
400+func (c *controllerV1) handleGetWorkspaceSessionMessages(w http.ResponseWriter, r *http.Request) {
401+ id := r.PathValue("id")
402+ sid := r.PathValue("sid")
403+ messages, err := c.backend.ListSessionMessages(r.Context(), id, sid)
404+ if err != nil {
405+ c.handleError(w, r, err)
406+ return
407+ }
408+ jsonEncode(w, messagesToProto(messages))
409+}
410+
411+// handlePutWorkspaceSession updates a session.
412+//
413+// @Summary Update session
414+// @Tags sessions
415+// @Accept json
416+// @Produce json
417+// @Param id path string true "Workspace ID"
418+// @Param sid path string true "Session ID"
419+// @Param request body proto.Session true "Updated session"
420+// @Success 200 {object} proto.Session
421+// @Failure 400 {object} proto.Error
422+// @Failure 404 {object} proto.Error
423+// @Failure 500 {object} proto.Error
424+// @Router /workspaces/{id}/sessions/{sid} [put]
425+func (c *controllerV1) handlePutWorkspaceSession(w http.ResponseWriter, r *http.Request) {
426+ id := r.PathValue("id")
427+
428+ var sess session.Session
429+ if err := json.NewDecoder(r.Body).Decode(&sess); err != nil {
430+ c.server.logError(r, "Failed to decode request", "error", err)
431+ jsonError(w, http.StatusBadRequest, "failed to decode request")
432+ return
433+ }
434+
435+ saved, err := c.backend.SaveSession(r.Context(), id, sess)
436+ if err != nil {
437+ c.handleError(w, r, err)
438+ return
439+ }
440+ jsonEncode(w, sessionToProto(saved))
441+}
442+
443+// handleDeleteWorkspaceSession deletes a session.
444+//
445+// @Summary Delete session
446+// @Tags sessions
447+// @Param id path string true "Workspace ID"
448+// @Param sid path string true "Session ID"
449+// @Success 200
450+// @Failure 404 {object} proto.Error
451+// @Failure 500 {object} proto.Error
452+// @Router /workspaces/{id}/sessions/{sid} [delete]
453+func (c *controllerV1) handleDeleteWorkspaceSession(w http.ResponseWriter, r *http.Request) {
454+ id := r.PathValue("id")
455+ sid := r.PathValue("sid")
456+ if err := c.backend.DeleteSession(r.Context(), id, sid); err != nil {
457+ c.handleError(w, r, err)
458+ return
459+ }
460+ w.WriteHeader(http.StatusOK)
461+}
462+
463+// handleGetWorkspaceSessionUserMessages returns user messages for a session.
464+//
465+// @Summary Get user messages for session
466+// @Tags sessions
467+// @Produce json
468+// @Param id path string true "Workspace ID"
469+// @Param sid path string true "Session ID"
470+// @Success 200 {array} proto.Message
471+// @Failure 404 {object} proto.Error
472+// @Failure 500 {object} proto.Error
473+// @Router /workspaces/{id}/sessions/{sid}/messages/user [get]
474+func (c *controllerV1) handleGetWorkspaceSessionUserMessages(w http.ResponseWriter, r *http.Request) {
475+ id := r.PathValue("id")
476+ sid := r.PathValue("sid")
477+ messages, err := c.backend.ListUserMessages(r.Context(), id, sid)
478+ if err != nil {
479+ c.handleError(w, r, err)
480+ return
481+ }
482+ jsonEncode(w, messagesToProto(messages))
483+}
484+
485+// handleGetWorkspaceAllUserMessages returns all user messages across sessions.
486+//
487+// @Summary Get all user messages for workspace
488+// @Tags workspaces
489+// @Produce json
490+// @Param id path string true "Workspace ID"
491+// @Success 200 {array} proto.Message
492+// @Failure 404 {object} proto.Error
493+// @Failure 500 {object} proto.Error
494+// @Router /workspaces/{id}/messages/user [get]
495+func (c *controllerV1) handleGetWorkspaceAllUserMessages(w http.ResponseWriter, r *http.Request) {
496+ id := r.PathValue("id")
497+ messages, err := c.backend.ListAllUserMessages(r.Context(), id)
498+ if err != nil {
499+ c.handleError(w, r, err)
500+ return
501+ }
502+ jsonEncode(w, messagesToProto(messages))
503+}
504+
505+// handleGetWorkspaceSessionFileTrackerFiles lists files read in a session.
506+//
507+// @Summary List tracked files for session
508+// @Tags filetracker
509+// @Produce json
510+// @Param id path string true "Workspace ID"
511+// @Param sid path string true "Session ID"
512+// @Success 200 {array} string
513+// @Failure 404 {object} proto.Error
514+// @Failure 500 {object} proto.Error
515+// @Router /workspaces/{id}/sessions/{sid}/filetracker/files [get]
516+func (c *controllerV1) handleGetWorkspaceSessionFileTrackerFiles(w http.ResponseWriter, r *http.Request) {
517+ id := r.PathValue("id")
518+ sid := r.PathValue("sid")
519+ files, err := c.backend.FileTrackerListReadFiles(r.Context(), id, sid)
520+ if err != nil {
521+ c.handleError(w, r, err)
522+ return
523+ }
524+ jsonEncode(w, files)
525+}
526+
527+// handlePostWorkspaceFileTrackerRead records a file read event.
528+//
529+// @Summary Record file read
530+// @Tags filetracker
531+// @Accept json
532+// @Param id path string true "Workspace ID"
533+// @Param request body proto.FileTrackerReadRequest true "File tracker read request"
534+// @Success 200
535+// @Failure 400 {object} proto.Error
536+// @Failure 404 {object} proto.Error
537+// @Failure 500 {object} proto.Error
538+// @Router /workspaces/{id}/filetracker/read [post]
539+func (c *controllerV1) handlePostWorkspaceFileTrackerRead(w http.ResponseWriter, r *http.Request) {
540+ id := r.PathValue("id")
541+
542+ var req proto.FileTrackerReadRequest
543+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
544+ c.server.logError(r, "Failed to decode request", "error", err)
545+ jsonError(w, http.StatusBadRequest, "failed to decode request")
546+ return
547+ }
548+
549+ if err := c.backend.FileTrackerRecordRead(r.Context(), id, req.SessionID, req.Path); err != nil {
550+ c.handleError(w, r, err)
551+ return
552+ }
553+ w.WriteHeader(http.StatusOK)
554+}
555+
556+// handleGetWorkspaceFileTrackerLastRead returns the last read time for a file.
557+//
558+// @Summary Get last read time for file
559+// @Tags filetracker
560+// @Produce json
561+// @Param id path string true "Workspace ID"
562+// @Param session_id query string false "Session ID"
563+// @Param path query string true "File path"
564+// @Success 200 {object} object
565+// @Failure 404 {object} proto.Error
566+// @Failure 500 {object} proto.Error
567+// @Router /workspaces/{id}/filetracker/lastread [get]
568+func (c *controllerV1) handleGetWorkspaceFileTrackerLastRead(w http.ResponseWriter, r *http.Request) {
569+ id := r.PathValue("id")
570+ sid := r.URL.Query().Get("session_id")
571+ path := r.URL.Query().Get("path")
572+
573+ t, err := c.backend.FileTrackerLastReadTime(r.Context(), id, sid, path)
574+ if err != nil {
575+ c.handleError(w, r, err)
576+ return
577+ }
578+ jsonEncode(w, t)
579+}
580+
581+// handlePostWorkspaceLSPStart starts an LSP server for a path.
582+//
583+// @Summary Start LSP server
584+// @Tags lsp
585+// @Accept json
586+// @Param id path string true "Workspace ID"
587+// @Param request body proto.LSPStartRequest true "LSP start request"
588+// @Success 200
589+// @Failure 400 {object} proto.Error
590+// @Failure 404 {object} proto.Error
591+// @Failure 500 {object} proto.Error
592+// @Router /workspaces/{id}/lsps/start [post]
593+func (c *controllerV1) handlePostWorkspaceLSPStart(w http.ResponseWriter, r *http.Request) {
594+ id := r.PathValue("id")
595+
596+ var req proto.LSPStartRequest
597+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
598+ c.server.logError(r, "Failed to decode request", "error", err)
599+ jsonError(w, http.StatusBadRequest, "failed to decode request")
600+ return
601+ }
602+
603+ if err := c.backend.LSPStart(r.Context(), id, req.Path); err != nil {
604+ c.handleError(w, r, err)
605+ return
606+ }
607+ w.WriteHeader(http.StatusOK)
608+}
609+
610+// handlePostWorkspaceLSPStopAll stops all LSP servers.
611+//
612+// @Summary Stop all LSP servers
613+// @Tags lsp
614+// @Param id path string true "Workspace ID"
615+// @Success 200
616+// @Failure 404 {object} proto.Error
617+// @Failure 500 {object} proto.Error
618+// @Router /workspaces/{id}/lsps/stop [post]
619+func (c *controllerV1) handlePostWorkspaceLSPStopAll(w http.ResponseWriter, r *http.Request) {
620+ id := r.PathValue("id")
621+ if err := c.backend.LSPStopAll(r.Context(), id); err != nil {
622+ c.handleError(w, r, err)
623+ return
624+ }
625+ w.WriteHeader(http.StatusOK)
626+}
627+
628+// handleGetWorkspaceAgent returns agent info for a workspace.
629+//
630+// @Summary Get agent info
631+// @Tags agent
632+// @Produce json
633+// @Param id path string true "Workspace ID"
634+// @Success 200 {object} proto.AgentInfo
635+// @Failure 404 {object} proto.Error
636+// @Failure 500 {object} proto.Error
637+// @Router /workspaces/{id}/agent [get]
638+func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
639+ id := r.PathValue("id")
640+ info, err := c.backend.GetAgentInfo(id)
641+ if err != nil {
642+ c.handleError(w, r, err)
643+ return
644+ }
645+ jsonEncode(w, info)
646+}
647+
648+// handlePostWorkspaceAgent sends a message to the agent.
649+//
650+// @Summary Send message to agent
651+// @Tags agent
652+// @Accept json
653+// @Param id path string true "Workspace ID"
654+// @Param request body proto.AgentMessage true "Agent message"
655+// @Success 200
656+// @Failure 400 {object} proto.Error
657+// @Failure 404 {object} proto.Error
658+// @Failure 500 {object} proto.Error
659+// @Router /workspaces/{id}/agent [post]
660+func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
661+ id := r.PathValue("id")
662+
663+ var msg proto.AgentMessage
664+ if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
665+ c.server.logError(r, "Failed to decode request", "error", err)
666+ jsonError(w, http.StatusBadRequest, "failed to decode request")
667+ return
668+ }
669+
670+ if err := c.backend.SendMessage(r.Context(), id, msg); err != nil {
671+ c.handleError(w, r, err)
672+ return
673+ }
674+ w.WriteHeader(http.StatusOK)
675+}
676+
677+// handlePostWorkspaceAgentInit initializes the agent for a workspace.
678+//
679+// @Summary Initialize agent
680+// @Tags agent
681+// @Param id path string true "Workspace ID"
682+// @Success 200
683+// @Failure 404 {object} proto.Error
684+// @Failure 500 {object} proto.Error
685+// @Router /workspaces/{id}/agent/init [post]
686+func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
687+ id := r.PathValue("id")
688+ if err := c.backend.InitAgent(r.Context(), id); err != nil {
689+ c.handleError(w, r, err)
690+ return
691+ }
692+ w.WriteHeader(http.StatusOK)
693+}
694+
695+// handlePostWorkspaceAgentUpdate updates the agent for a workspace.
696+//
697+// @Summary Update agent
698+// @Tags agent
699+// @Param id path string true "Workspace ID"
700+// @Success 200
701+// @Failure 404 {object} proto.Error
702+// @Failure 500 {object} proto.Error
703+// @Router /workspaces/{id}/agent/update [post]
704+func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
705+ id := r.PathValue("id")
706+ if err := c.backend.UpdateAgent(r.Context(), id); err != nil {
707+ c.handleError(w, r, err)
708+ return
709+ }
710+ w.WriteHeader(http.StatusOK)
711+}
712+
713+// handleGetWorkspaceAgentSession returns a specific agent session.
714+//
715+// @Summary Get agent session
716+// @Tags agent
717+// @Produce json
718+// @Param id path string true "Workspace ID"
719+// @Param sid path string true "Session ID"
720+// @Success 200 {object} proto.AgentSession
721+// @Failure 404 {object} proto.Error
722+// @Failure 500 {object} proto.Error
723+// @Router /workspaces/{id}/agent/sessions/{sid} [get]
724+func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
725+ id := r.PathValue("id")
726+ sid := r.PathValue("sid")
727+ agentSession, err := c.backend.GetAgentSession(r.Context(), id, sid)
728+ if err != nil {
729+ c.handleError(w, r, err)
730+ return
731+ }
732+ jsonEncode(w, agentSession)
733+}
734+
735+// handlePostWorkspaceAgentSessionCancel cancels a running agent session.
736+//
737+// @Summary Cancel agent session
738+// @Tags agent
739+// @Param id path string true "Workspace ID"
740+// @Param sid path string true "Session ID"
741+// @Success 200
742+// @Failure 404 {object} proto.Error
743+// @Failure 500 {object} proto.Error
744+// @Router /workspaces/{id}/agent/sessions/{sid}/cancel [post]
745+func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) {
746+ id := r.PathValue("id")
747+ sid := r.PathValue("sid")
748+ if err := c.backend.CancelSession(id, sid); err != nil {
749+ c.handleError(w, r, err)
750+ return
751+ }
752+ w.WriteHeader(http.StatusOK)
753+}
754+
755+// handleGetWorkspaceAgentSessionPromptQueued returns whether a queued prompt exists.
756+//
757+// @Summary Get queued prompt status
758+// @Tags agent
759+// @Produce json
760+// @Param id path string true "Workspace ID"
761+// @Param sid path string true "Session ID"
762+// @Success 200 {object} object
763+// @Failure 404 {object} proto.Error
764+// @Failure 500 {object} proto.Error
765+// @Router /workspaces/{id}/agent/sessions/{sid}/prompts/queued [get]
766+func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) {
767+ id := r.PathValue("id")
768+ sid := r.PathValue("sid")
769+ queued, err := c.backend.QueuedPrompts(id, sid)
770+ if err != nil {
771+ c.handleError(w, r, err)
772+ return
773+ }
774+ jsonEncode(w, queued)
775+}
776+
777+// handlePostWorkspaceAgentSessionPromptClear clears the prompt queue for a session.
778+//
779+// @Summary Clear prompt queue
780+// @Tags agent
781+// @Param id path string true "Workspace ID"
782+// @Param sid path string true "Session ID"
783+// @Success 200
784+// @Failure 404 {object} proto.Error
785+// @Failure 500 {object} proto.Error
786+// @Router /workspaces/{id}/agent/sessions/{sid}/prompts/clear [post]
787+func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) {
788+ id := r.PathValue("id")
789+ sid := r.PathValue("sid")
790+ if err := c.backend.ClearQueue(id, sid); err != nil {
791+ c.handleError(w, r, err)
792+ return
793+ }
794+ w.WriteHeader(http.StatusOK)
795+}
796+
797+// handlePostWorkspaceAgentSessionSummarize summarizes a session.
798+//
799+// @Summary Summarize session
800+// @Tags agent
801+// @Param id path string true "Workspace ID"
802+// @Param sid path string true "Session ID"
803+// @Success 200
804+// @Failure 404 {object} proto.Error
805+// @Failure 500 {object} proto.Error
806+// @Router /workspaces/{id}/agent/sessions/{sid}/summarize [post]
807+func (c *controllerV1) handlePostWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
808+ id := r.PathValue("id")
809+ sid := r.PathValue("sid")
810+ if err := c.backend.SummarizeSession(r.Context(), id, sid); err != nil {
811+ c.handleError(w, r, err)
812+ return
813+ }
814+ w.WriteHeader(http.StatusOK)
815+}
816+
817+// handleGetWorkspaceAgentSessionPromptList returns the list of queued prompts.
818+//
819+// @Summary List queued prompts
820+// @Tags agent
821+// @Produce json
822+// @Param id path string true "Workspace ID"
823+// @Param sid path string true "Session ID"
824+// @Success 200 {array} string
825+// @Failure 404 {object} proto.Error
826+// @Failure 500 {object} proto.Error
827+// @Router /workspaces/{id}/agent/sessions/{sid}/prompts/list [get]
828+func (c *controllerV1) handleGetWorkspaceAgentSessionPromptList(w http.ResponseWriter, r *http.Request) {
829+ id := r.PathValue("id")
830+ sid := r.PathValue("sid")
831+ prompts, err := c.backend.QueuedPromptsList(id, sid)
832+ if err != nil {
833+ c.handleError(w, r, err)
834+ return
835+ }
836+ jsonEncode(w, prompts)
837+}
838+
839+// handleGetWorkspaceAgentDefaultSmallModel returns the default small model for a provider.
840+//
841+// @Summary Get default small model
842+// @Tags agent
843+// @Produce json
844+// @Param id path string true "Workspace ID"
845+// @Param provider_id query string false "Provider ID"
846+// @Success 200 {object} object
847+// @Failure 404 {object} proto.Error
848+// @Failure 500 {object} proto.Error
849+// @Router /workspaces/{id}/agent/default-small-model [get]
850+func (c *controllerV1) handleGetWorkspaceAgentDefaultSmallModel(w http.ResponseWriter, r *http.Request) {
851+ id := r.PathValue("id")
852+ providerID := r.URL.Query().Get("provider_id")
853+ model, err := c.backend.GetDefaultSmallModel(id, providerID)
854+ if err != nil {
855+ c.handleError(w, r, err)
856+ return
857+ }
858+ jsonEncode(w, model)
859+}
860+
861+// handlePostWorkspacePermissionsGrant grants a permission request.
862+//
863+// @Summary Grant permission
864+// @Tags permissions
865+// @Accept json
866+// @Param id path string true "Workspace ID"
867+// @Param request body proto.PermissionGrant true "Permission grant"
868+// @Success 200
869+// @Failure 400 {object} proto.Error
870+// @Failure 404 {object} proto.Error
871+// @Failure 500 {object} proto.Error
872+// @Router /workspaces/{id}/permissions/grant [post]
873+func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
874+ id := r.PathValue("id")
875+
876+ var req proto.PermissionGrant
877+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
878+ c.server.logError(r, "Failed to decode request", "error", err)
879+ jsonError(w, http.StatusBadRequest, "failed to decode request")
880+ return
881+ }
882+
883+ if err := c.backend.GrantPermission(id, req); err != nil {
884+ c.handleError(w, r, err)
885+ return
886+ }
887+ w.WriteHeader(http.StatusOK)
888+}
889+
890+// handlePostWorkspacePermissionsSkip sets whether to skip permission prompts.
891+//
892+// @Summary Set skip permissions
893+// @Tags permissions
894+// @Accept json
895+// @Param id path string true "Workspace ID"
896+// @Param request body proto.PermissionSkipRequest true "Permission skip request"
897+// @Success 200
898+// @Failure 400 {object} proto.Error
899+// @Failure 404 {object} proto.Error
900+// @Failure 500 {object} proto.Error
901+// @Router /workspaces/{id}/permissions/skip [post]
902+func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
903+ id := r.PathValue("id")
904+
905+ var req proto.PermissionSkipRequest
906+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
907+ c.server.logError(r, "Failed to decode request", "error", err)
908+ jsonError(w, http.StatusBadRequest, "failed to decode request")
909+ return
910+ }
911+
912+ if err := c.backend.SetPermissionsSkip(id, req.Skip); err != nil {
913+ c.handleError(w, r, err)
914+ return
915+ }
916+}
917+
918+// handleGetWorkspacePermissionsSkip returns whether permission prompts are skipped.
919+//
920+// @Summary Get skip permissions status
921+// @Tags permissions
922+// @Produce json
923+// @Param id path string true "Workspace ID"
924+// @Success 200 {object} proto.PermissionSkipRequest
925+// @Failure 404 {object} proto.Error
926+// @Failure 500 {object} proto.Error
927+// @Router /workspaces/{id}/permissions/skip [get]
928+func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
929+ id := r.PathValue("id")
930+ skip, err := c.backend.GetPermissionsSkip(id)
931+ if err != nil {
932+ c.handleError(w, r, err)
933+ return
934+ }
935+ jsonEncode(w, proto.PermissionSkipRequest{Skip: skip})
936+}
937+
938+// handleError maps backend errors to HTTP status codes and writes the
939+// JSON error response.
940+func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err error) {
941+ status := http.StatusInternalServerError
942+ switch {
943+ case errors.Is(err, backend.ErrWorkspaceNotFound):
944+ status = http.StatusNotFound
945+ case errors.Is(err, backend.ErrLSPClientNotFound):
946+ status = http.StatusNotFound
947+ case errors.Is(err, backend.ErrAgentNotInitialized):
948+ status = http.StatusBadRequest
949+ case errors.Is(err, backend.ErrPathRequired):
950+ status = http.StatusBadRequest
951+ case errors.Is(err, backend.ErrInvalidPermissionAction):
952+ status = http.StatusBadRequest
953+ case errors.Is(err, backend.ErrUnknownCommand):
954+ status = http.StatusBadRequest
955+ }
956+ c.server.logError(r, err.Error())
957+ jsonError(w, status, err.Error())
958+}
959+
960+func jsonEncode(w http.ResponseWriter, v any) {
961+ w.Header().Set("Content-Type", "application/json")
962+ _ = json.NewEncoder(w).Encode(v)
963+}
964+
965+func jsonError(w http.ResponseWriter, status int, message string) {
966+ w.Header().Set("Content-Type", "application/json")
967+ w.WriteHeader(status)
968+ _ = json.NewEncoder(w).Encode(proto.Error{Message: message})
969+}
internal/server/server.gonew file+234
@@ -0,0 +1,234 @@
1+package server
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+ "net"
8+ "net/http"
9+ "net/url"
10+ "os/user"
11+ "runtime"
12+ "strings"
13+
14+ "github.com/charmbracelet/crush/internal/backend"
15+ "github.com/charmbracelet/crush/internal/config"
16+ _ "github.com/charmbracelet/crush/internal/swagger"
17+ httpswagger "github.com/swaggo/http-swagger/v2"
18+)
19+
20+// ErrServerClosed is returned when the server is closed.
21+var ErrServerClosed = http.ErrServerClosed
22+
23+// ParseHostURL parses a host URL into a [url.URL].
24+func ParseHostURL(host string) (*url.URL, error) {
25+ proto, addr, ok := strings.Cut(host, "://")
26+ if !ok {
27+ return nil, fmt.Errorf("invalid host format: %s", host)
28+ }
29+
30+ var basePath string
31+ if proto == "tcp" {
32+ parsed, err := url.Parse("tcp://" + addr)
33+ if err != nil {
34+ return nil, fmt.Errorf("invalid tcp address: %v", err)
35+ }
36+ addr = parsed.Host
37+ basePath = parsed.Path
38+ }
39+ return &url.URL{
40+ Scheme: proto,
41+ Host: addr,
42+ Path: basePath,
43+ }, nil
44+}
45+
46+// DefaultHost returns the default server host.
47+func DefaultHost() string {
48+ sock := "crush.sock"
49+ usr, err := user.Current()
50+ if err == nil && usr.Uid != "" {
51+ sock = fmt.Sprintf("crush-%s.sock", usr.Uid)
52+ }
53+ if runtime.GOOS == "windows" {
54+ return fmt.Sprintf("npipe:////./pipe/%s", sock)
55+ }
56+ return fmt.Sprintf("unix:///tmp/%s", sock)
57+}
58+
59+// Server represents a Crush server bound to a specific address.
60+type Server struct {
61+ // Addr can be a TCP address, a Unix socket path, or a Windows named pipe.
62+ Addr string
63+ network string
64+
65+ h *http.Server
66+ ln net.Listener
67+
68+ backend *backend.Backend
69+ logger *slog.Logger
70+}
71+
72+// SetLogger sets the logger for the server.
73+func (s *Server) SetLogger(logger *slog.Logger) {
74+ s.logger = logger
75+}
76+
77+// DefaultServer returns a new [Server] with the default address.
78+func DefaultServer(cfg *config.ConfigStore) *Server {
79+ hostURL, err := ParseHostURL(DefaultHost())
80+ if err != nil {
81+ panic("invalid default host")
82+ }
83+ return NewServer(cfg, hostURL.Scheme, hostURL.Host)
84+}
85+
86+// NewServer creates a new [Server] with the given network and address.
87+func NewServer(cfg *config.ConfigStore, network, address string) *Server {
88+ s := new(Server)
89+ s.Addr = address
90+ s.network = network
91+
92+ // The backend is created with a shutdown callback that triggers
93+ // a graceful server shutdown (e.g. when the last workspace is
94+ // removed).
95+ s.backend = backend.New(context.Background(), cfg, func() {
96+ go func() {
97+ slog.Info("Shutting down server...")
98+ if err := s.Shutdown(context.Background()); err != nil {
99+ slog.Error("Failed to shutdown server", "error", err)
100+ }
101+ }()
102+ })
103+
104+ var p http.Protocols
105+ p.SetHTTP1(true)
106+ p.SetUnencryptedHTTP2(true)
107+ c := &controllerV1{backend: s.backend, server: s}
108+ mux := http.NewServeMux()
109+ mux.HandleFunc("GET /v1/health", c.handleGetHealth)
110+ mux.HandleFunc("GET /v1/version", c.handleGetVersion)
111+ mux.HandleFunc("GET /v1/config", c.handleGetConfig)
112+ mux.HandleFunc("POST /v1/control", c.handlePostControl)
113+ mux.HandleFunc("GET /v1/workspaces", c.handleGetWorkspaces)
114+ mux.HandleFunc("POST /v1/workspaces", c.handlePostWorkspaces)
115+ mux.HandleFunc("DELETE /v1/workspaces/{id}", c.handleDeleteWorkspaces)
116+ mux.HandleFunc("GET /v1/workspaces/{id}", c.handleGetWorkspace)
117+ mux.HandleFunc("GET /v1/workspaces/{id}/config", c.handleGetWorkspaceConfig)
118+ mux.HandleFunc("GET /v1/workspaces/{id}/events", c.handleGetWorkspaceEvents)
119+ mux.HandleFunc("GET /v1/workspaces/{id}/providers", c.handleGetWorkspaceProviders)
120+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions", c.handleGetWorkspaceSessions)
121+ mux.HandleFunc("POST /v1/workspaces/{id}/sessions", c.handlePostWorkspaceSessions)
122+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}", c.handleGetWorkspaceSession)
123+ mux.HandleFunc("PUT /v1/workspaces/{id}/sessions/{sid}", c.handlePutWorkspaceSession)
124+ mux.HandleFunc("DELETE /v1/workspaces/{id}/sessions/{sid}", c.handleDeleteWorkspaceSession)
125+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/history", c.handleGetWorkspaceSessionHistory)
126+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/messages", c.handleGetWorkspaceSessionMessages)
127+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/messages/user", c.handleGetWorkspaceSessionUserMessages)
128+ mux.HandleFunc("GET /v1/workspaces/{id}/messages/user", c.handleGetWorkspaceAllUserMessages)
129+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/filetracker/files", c.handleGetWorkspaceSessionFileTrackerFiles)
130+ mux.HandleFunc("POST /v1/workspaces/{id}/filetracker/read", c.handlePostWorkspaceFileTrackerRead)
131+ mux.HandleFunc("GET /v1/workspaces/{id}/filetracker/lastread", c.handleGetWorkspaceFileTrackerLastRead)
132+ mux.HandleFunc("GET /v1/workspaces/{id}/lsps", c.handleGetWorkspaceLSPs)
133+ mux.HandleFunc("GET /v1/workspaces/{id}/lsps/{lsp}/diagnostics", c.handleGetWorkspaceLSPDiagnostics)
134+ mux.HandleFunc("POST /v1/workspaces/{id}/lsps/start", c.handlePostWorkspaceLSPStart)
135+ mux.HandleFunc("POST /v1/workspaces/{id}/lsps/stop", c.handlePostWorkspaceLSPStopAll)
136+ mux.HandleFunc("GET /v1/workspaces/{id}/permissions/skip", c.handleGetWorkspacePermissionsSkip)
137+ mux.HandleFunc("POST /v1/workspaces/{id}/permissions/skip", c.handlePostWorkspacePermissionsSkip)
138+ mux.HandleFunc("POST /v1/workspaces/{id}/permissions/grant", c.handlePostWorkspacePermissionsGrant)
139+ mux.HandleFunc("GET /v1/workspaces/{id}/agent", c.handleGetWorkspaceAgent)
140+ mux.HandleFunc("POST /v1/workspaces/{id}/agent", c.handlePostWorkspaceAgent)
141+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/init", c.handlePostWorkspaceAgentInit)
142+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/update", c.handlePostWorkspaceAgentUpdate)
143+ mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}", c.handleGetWorkspaceAgentSession)
144+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/cancel", c.handlePostWorkspaceAgentSessionCancel)
145+ mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/queued", c.handleGetWorkspaceAgentSessionPromptQueued)
146+ mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/list", c.handleGetWorkspaceAgentSessionPromptList)
147+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/prompts/clear", c.handlePostWorkspaceAgentSessionPromptClear)
148+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/summarize", c.handlePostWorkspaceAgentSessionSummarize)
149+ mux.HandleFunc("GET /v1/workspaces/{id}/agent/default-small-model", c.handleGetWorkspaceAgentDefaultSmallModel)
150+ mux.HandleFunc("POST /v1/workspaces/{id}/config/set", c.handlePostWorkspaceConfigSet)
151+ mux.HandleFunc("POST /v1/workspaces/{id}/config/remove", c.handlePostWorkspaceConfigRemove)
152+ mux.HandleFunc("POST /v1/workspaces/{id}/config/model", c.handlePostWorkspaceConfigModel)
153+ mux.HandleFunc("POST /v1/workspaces/{id}/config/compact", c.handlePostWorkspaceConfigCompact)
154+ mux.HandleFunc("POST /v1/workspaces/{id}/config/provider-key", c.handlePostWorkspaceConfigProviderKey)
155+ mux.HandleFunc("POST /v1/workspaces/{id}/config/import-copilot", c.handlePostWorkspaceConfigImportCopilot)
156+ mux.HandleFunc("POST /v1/workspaces/{id}/config/refresh-oauth", c.handlePostWorkspaceConfigRefreshOAuth)
157+ mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
158+ mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
159+ mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
160+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
161+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
162+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)
163+ mux.HandleFunc("GET /v1/workspaces/{id}/mcp/states", c.handleGetWorkspaceMCPStates)
164+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-prompts", c.handlePostWorkspaceMCPRefreshPrompts)
165+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-resources", c.handlePostWorkspaceMCPRefreshResources)
166+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/docker/enable", c.handlePostWorkspaceMCPEnableDocker)
167+ mux.HandleFunc("POST /v1/workspaces/{id}/mcp/docker/disable", c.handlePostWorkspaceMCPDisableDocker)
168+ mux.Handle("/v1/docs/", httpswagger.WrapHandler)
169+ s.h = &http.Server{
170+ Protocols: &p,
171+ Handler: s.loggingHandler(mux),
172+ }
173+ if network == "tcp" {
174+ s.h.Addr = address
175+ }
176+ return s
177+}
178+
179+// Serve accepts incoming connections on the listener.
180+func (s *Server) Serve(ln net.Listener) error {
181+ return s.h.Serve(ln)
182+}
183+
184+// ListenAndServe starts the server and begins accepting connections.
185+func (s *Server) ListenAndServe() error {
186+ if s.ln != nil {
187+ return fmt.Errorf("server already started")
188+ }
189+ ln, err := listen(s.network, s.Addr)
190+ if err != nil {
191+ return fmt.Errorf("failed to listen on %s: %w", s.Addr, err)
192+ }
193+ return s.Serve(ln)
194+}
195+
196+func (s *Server) closeListener() {
197+ if s.ln != nil {
198+ s.ln.Close()
199+ s.ln = nil
200+ }
201+}
202+
203+// Close force closes all listeners and connections.
204+func (s *Server) Close() error {
205+ defer func() { s.closeListener() }()
206+ return s.h.Close()
207+}
208+
209+// Shutdown gracefully shuts down the server without interrupting active
210+// connections.
211+func (s *Server) Shutdown(ctx context.Context) error {
212+ defer func() { s.closeListener() }()
213+ return s.h.Shutdown(ctx)
214+}
215+
216+func (s *Server) logDebug(r *http.Request, msg string, args ...any) {
217+ if s.logger != nil {
218+ s.logger.With(
219+ slog.String("method", r.Method),
220+ slog.String("url", r.URL.String()),
221+ slog.String("remote_addr", r.RemoteAddr),
222+ ).Debug(msg, args...)
223+ }
224+}
225+
226+func (s *Server) logError(r *http.Request, msg string, args ...any) {
227+ if s.logger != nil {
228+ s.logger.With(
229+ slog.String("method", r.Method),
230+ slog.String("url", r.URL.String()),
231+ slog.String("remote_addr", r.RemoteAddr),
232+ ).Error(msg, args...)
233+ }
234+}
internal/swagger/docs.gonew file+3589
@@ -0,0 +1,3589 @@
1+// Package swagger Code generated by swaggo/swag. DO NOT EDIT
2+package swagger
3+
4+import "github.com/swaggo/swag"
5+
6+const docTemplate = `{
7+ "schemes": {{ marshal .Schemes }},
8+ "swagger": "2.0",
9+ "info": {
10+ "description": "{{escape .Description}}",
11+ "title": "{{.Title}}",
12+ "contact": {
13+ "name": "Charm",
14+ "url": "https://charm.sh"
15+ },
16+ "license": {
17+ "name": "MIT",
18+ "url": "https://github.com/charmbracelet/crush/blob/main/LICENSE"
19+ },
20+ "version": "{{.Version}}"
21+ },
22+ "host": "{{.Host}}",
23+ "basePath": "{{.BasePath}}",
24+ "paths": {
25+ "/config": {
26+ "get": {
27+ "produces": [
28+ "application/json"
29+ ],
30+ "tags": [
31+ "system"
32+ ],
33+ "summary": "Get server config",
34+ "responses": {
35+ "200": {
36+ "description": "OK",
37+ "schema": {
38+ "type": "object"
39+ }
40+ }
41+ }
42+ }
43+ },
44+ "/control": {
45+ "post": {
46+ "consumes": [
47+ "application/json"
48+ ],
49+ "tags": [
50+ "system"
51+ ],
52+ "summary": "Send server control command",
53+ "parameters": [
54+ {
55+ "description": "Control command (e.g. shutdown)",
56+ "name": "request",
57+ "in": "body",
58+ "required": true,
59+ "schema": {
60+ "$ref": "#/definitions/proto.ServerControl"
61+ }
62+ }
63+ ],
64+ "responses": {
65+ "200": {
66+ "description": "OK"
67+ },
68+ "400": {
69+ "description": "Bad Request",
70+ "schema": {
71+ "$ref": "#/definitions/proto.Error"
72+ }
73+ }
74+ }
75+ }
76+ },
77+ "/health": {
78+ "get": {
79+ "tags": [
80+ "system"
81+ ],
82+ "summary": "Health check",
83+ "responses": {
84+ "200": {
85+ "description": "OK"
86+ }
87+ }
88+ }
89+ },
90+ "/version": {
91+ "get": {
92+ "produces": [
93+ "application/json"
94+ ],
95+ "tags": [
96+ "system"
97+ ],
98+ "summary": "Get server version",
99+ "responses": {
100+ "200": {
101+ "description": "OK",
102+ "schema": {
103+ "$ref": "#/definitions/proto.VersionInfo"
104+ }
105+ }
106+ }
107+ }
108+ },
109+ "/workspaces": {
110+ "get": {
111+ "produces": [
112+ "application/json"
113+ ],
114+ "tags": [
115+ "workspaces"
116+ ],
117+ "summary": "List workspaces",
118+ "responses": {
119+ "200": {
120+ "description": "OK",
121+ "schema": {
122+ "type": "array",
123+ "items": {
124+ "$ref": "#/definitions/proto.Workspace"
125+ }
126+ }
127+ }
128+ }
129+ },
130+ "post": {
131+ "consumes": [
132+ "application/json"
133+ ],
134+ "produces": [
135+ "application/json"
136+ ],
137+ "tags": [
138+ "workspaces"
139+ ],
140+ "summary": "Create workspace",
141+ "parameters": [
142+ {
143+ "description": "Workspace creation params",
144+ "name": "request",
145+ "in": "body",
146+ "required": true,
147+ "schema": {
148+ "$ref": "#/definitions/proto.Workspace"
149+ }
150+ }
151+ ],
152+ "responses": {
153+ "200": {
154+ "description": "OK",
155+ "schema": {
156+ "$ref": "#/definitions/proto.Workspace"
157+ }
158+ },
159+ "400": {
160+ "description": "Bad Request",
161+ "schema": {
162+ "$ref": "#/definitions/proto.Error"
163+ }
164+ },
165+ "500": {
166+ "description": "Internal Server Error",
167+ "schema": {
168+ "$ref": "#/definitions/proto.Error"
169+ }
170+ }
171+ }
172+ }
173+ },
174+ "/workspaces/{id}": {
175+ "get": {
176+ "produces": [
177+ "application/json"
178+ ],
179+ "tags": [
180+ "workspaces"
181+ ],
182+ "summary": "Get workspace",
183+ "parameters": [
184+ {
185+ "type": "string",
186+ "description": "Workspace ID",
187+ "name": "id",
188+ "in": "path",
189+ "required": true
190+ }
191+ ],
192+ "responses": {
193+ "200": {
194+ "description": "OK",
195+ "schema": {
196+ "$ref": "#/definitions/proto.Workspace"
197+ }
198+ },
199+ "404": {
200+ "description": "Not Found",
201+ "schema": {
202+ "$ref": "#/definitions/proto.Error"
203+ }
204+ },
205+ "500": {
206+ "description": "Internal Server Error",
207+ "schema": {
208+ "$ref": "#/definitions/proto.Error"
209+ }
210+ }
211+ }
212+ },
213+ "delete": {
214+ "tags": [
215+ "workspaces"
216+ ],
217+ "summary": "Delete workspace",
218+ "parameters": [
219+ {
220+ "type": "string",
221+ "description": "Workspace ID",
222+ "name": "id",
223+ "in": "path",
224+ "required": true
225+ }
226+ ],
227+ "responses": {
228+ "200": {
229+ "description": "OK"
230+ },
231+ "404": {
232+ "description": "Not Found",
233+ "schema": {
234+ "$ref": "#/definitions/proto.Error"
235+ }
236+ }
237+ }
238+ }
239+ },
240+ "/workspaces/{id}/agent": {
241+ "get": {
242+ "produces": [
243+ "application/json"
244+ ],
245+ "tags": [
246+ "agent"
247+ ],
248+ "summary": "Get agent info",
249+ "parameters": [
250+ {
251+ "type": "string",
252+ "description": "Workspace ID",
253+ "name": "id",
254+ "in": "path",
255+ "required": true
256+ }
257+ ],
258+ "responses": {
259+ "200": {
260+ "description": "OK",
261+ "schema": {
262+ "$ref": "#/definitions/proto.AgentInfo"
263+ }
264+ },
265+ "404": {
266+ "description": "Not Found",
267+ "schema": {
268+ "$ref": "#/definitions/proto.Error"
269+ }
270+ },
271+ "500": {
272+ "description": "Internal Server Error",
273+ "schema": {
274+ "$ref": "#/definitions/proto.Error"
275+ }
276+ }
277+ }
278+ },
279+ "post": {
280+ "consumes": [
281+ "application/json"
282+ ],
283+ "tags": [
284+ "agent"
285+ ],
286+ "summary": "Send message to agent",
287+ "parameters": [
288+ {
289+ "type": "string",
290+ "description": "Workspace ID",
291+ "name": "id",
292+ "in": "path",
293+ "required": true
294+ },
295+ {
296+ "description": "Agent message",
297+ "name": "request",
298+ "in": "body",
299+ "required": true,
300+ "schema": {
301+ "$ref": "#/definitions/proto.AgentMessage"
302+ }
303+ }
304+ ],
305+ "responses": {
306+ "200": {
307+ "description": "OK"
308+ },
309+ "400": {
310+ "description": "Bad Request",
311+ "schema": {
312+ "$ref": "#/definitions/proto.Error"
313+ }
314+ },
315+ "404": {
316+ "description": "Not Found",
317+ "schema": {
318+ "$ref": "#/definitions/proto.Error"
319+ }
320+ },
321+ "500": {
322+ "description": "Internal Server Error",
323+ "schema": {
324+ "$ref": "#/definitions/proto.Error"
325+ }
326+ }
327+ }
328+ }
329+ },
330+ "/workspaces/{id}/agent/default-small-model": {
331+ "get": {
332+ "produces": [
333+ "application/json"
334+ ],
335+ "tags": [
336+ "agent"
337+ ],
338+ "summary": "Get default small model",
339+ "parameters": [
340+ {
341+ "type": "string",
342+ "description": "Workspace ID",
343+ "name": "id",
344+ "in": "path",
345+ "required": true
346+ },
347+ {
348+ "type": "string",
349+ "description": "Provider ID",
350+ "name": "provider_id",
351+ "in": "query"
352+ }
353+ ],
354+ "responses": {
355+ "200": {
356+ "description": "OK",
357+ "schema": {
358+ "type": "object"
359+ }
360+ },
361+ "404": {
362+ "description": "Not Found",
363+ "schema": {
364+ "$ref": "#/definitions/proto.Error"
365+ }
366+ },
367+ "500": {
368+ "description": "Internal Server Error",
369+ "schema": {
370+ "$ref": "#/definitions/proto.Error"
371+ }
372+ }
373+ }
374+ }
375+ },
376+ "/workspaces/{id}/agent/init": {
377+ "post": {
378+ "tags": [
379+ "agent"
380+ ],
381+ "summary": "Initialize agent",
382+ "parameters": [
383+ {
384+ "type": "string",
385+ "description": "Workspace ID",
386+ "name": "id",
387+ "in": "path",
388+ "required": true
389+ }
390+ ],
391+ "responses": {
392+ "200": {
393+ "description": "OK"
394+ },
395+ "404": {
396+ "description": "Not Found",
397+ "schema": {
398+ "$ref": "#/definitions/proto.Error"
399+ }
400+ },
401+ "500": {
402+ "description": "Internal Server Error",
403+ "schema": {
404+ "$ref": "#/definitions/proto.Error"
405+ }
406+ }
407+ }
408+ }
409+ },
410+ "/workspaces/{id}/agent/sessions/{sid}": {
411+ "get": {
412+ "produces": [
413+ "application/json"
414+ ],
415+ "tags": [
416+ "agent"
417+ ],
418+ "summary": "Get agent session",
419+ "parameters": [
420+ {
421+ "type": "string",
422+ "description": "Workspace ID",
423+ "name": "id",
424+ "in": "path",
425+ "required": true
426+ },
427+ {
428+ "type": "string",
429+ "description": "Session ID",
430+ "name": "sid",
431+ "in": "path",
432+ "required": true
433+ }
434+ ],
435+ "responses": {
436+ "200": {
437+ "description": "OK",
438+ "schema": {
439+ "$ref": "#/definitions/proto.AgentSession"
440+ }
441+ },
442+ "404": {
443+ "description": "Not Found",
444+ "schema": {
445+ "$ref": "#/definitions/proto.Error"
446+ }
447+ },
448+ "500": {
449+ "description": "Internal Server Error",
450+ "schema": {
451+ "$ref": "#/definitions/proto.Error"
452+ }
453+ }
454+ }
455+ }
456+ },
457+ "/workspaces/{id}/agent/sessions/{sid}/cancel": {
458+ "post": {
459+ "tags": [
460+ "agent"
461+ ],
462+ "summary": "Cancel agent session",
463+ "parameters": [
464+ {
465+ "type": "string",
466+ "description": "Workspace ID",
467+ "name": "id",
468+ "in": "path",
469+ "required": true
470+ },
471+ {
472+ "type": "string",
473+ "description": "Session ID",
474+ "name": "sid",
475+ "in": "path",
476+ "required": true
477+ }
478+ ],
479+ "responses": {
480+ "200": {
481+ "description": "OK"
482+ },
483+ "404": {
484+ "description": "Not Found",
485+ "schema": {
486+ "$ref": "#/definitions/proto.Error"
487+ }
488+ },
489+ "500": {
490+ "description": "Internal Server Error",
491+ "schema": {
492+ "$ref": "#/definitions/proto.Error"
493+ }
494+ }
495+ }
496+ }
497+ },
498+ "/workspaces/{id}/agent/sessions/{sid}/prompts/clear": {
499+ "post": {
500+ "tags": [
501+ "agent"
502+ ],
503+ "summary": "Clear prompt queue",
504+ "parameters": [
505+ {
506+ "type": "string",
507+ "description": "Workspace ID",
508+ "name": "id",
509+ "in": "path",
510+ "required": true
511+ },
512+ {
513+ "type": "string",
514+ "description": "Session ID",
515+ "name": "sid",
516+ "in": "path",
517+ "required": true
518+ }
519+ ],
520+ "responses": {
521+ "200": {
522+ "description": "OK"
523+ },
524+ "404": {
525+ "description": "Not Found",
526+ "schema": {
527+ "$ref": "#/definitions/proto.Error"
528+ }
529+ },
530+ "500": {
531+ "description": "Internal Server Error",
532+ "schema": {
533+ "$ref": "#/definitions/proto.Error"
534+ }
535+ }
536+ }
537+ }
538+ },
539+ "/workspaces/{id}/agent/sessions/{sid}/prompts/list": {
540+ "get": {
541+ "produces": [
542+ "application/json"
543+ ],
544+ "tags": [
545+ "agent"
546+ ],
547+ "summary": "List queued prompts",
548+ "parameters": [
549+ {
550+ "type": "string",
551+ "description": "Workspace ID",
552+ "name": "id",
553+ "in": "path",
554+ "required": true
555+ },
556+ {
557+ "type": "string",
558+ "description": "Session ID",
559+ "name": "sid",
560+ "in": "path",
561+ "required": true
562+ }
563+ ],
564+ "responses": {
565+ "200": {
566+ "description": "OK",
567+ "schema": {
568+ "type": "array",
569+ "items": {
570+ "type": "string"
571+ }
572+ }
573+ },
574+ "404": {
575+ "description": "Not Found",
576+ "schema": {
577+ "$ref": "#/definitions/proto.Error"
578+ }
579+ },
580+ "500": {
581+ "description": "Internal Server Error",
582+ "schema": {
583+ "$ref": "#/definitions/proto.Error"
584+ }
585+ }
586+ }
587+ }
588+ },
589+ "/workspaces/{id}/agent/sessions/{sid}/prompts/queued": {
590+ "get": {
591+ "produces": [
592+ "application/json"
593+ ],
594+ "tags": [
595+ "agent"
596+ ],
597+ "summary": "Get queued prompt status",
598+ "parameters": [
599+ {
600+ "type": "string",
601+ "description": "Workspace ID",
602+ "name": "id",
603+ "in": "path",
604+ "required": true
605+ },
606+ {
607+ "type": "string",
608+ "description": "Session ID",
609+ "name": "sid",
610+ "in": "path",
611+ "required": true
612+ }
613+ ],
614+ "responses": {
615+ "200": {
616+ "description": "OK",
617+ "schema": {
618+ "type": "object"
619+ }
620+ },
621+ "404": {
622+ "description": "Not Found",
623+ "schema": {
624+ "$ref": "#/definitions/proto.Error"
625+ }
626+ },
627+ "500": {
628+ "description": "Internal Server Error",
629+ "schema": {
630+ "$ref": "#/definitions/proto.Error"
631+ }
632+ }
633+ }
634+ }
635+ },
636+ "/workspaces/{id}/agent/sessions/{sid}/summarize": {
637+ "post": {
638+ "tags": [
639+ "agent"
640+ ],
641+ "summary": "Summarize session",
642+ "parameters": [
643+ {
644+ "type": "string",
645+ "description": "Workspace ID",
646+ "name": "id",
647+ "in": "path",
648+ "required": true
649+ },
650+ {
651+ "type": "string",
652+ "description": "Session ID",
653+ "name": "sid",
654+ "in": "path",
655+ "required": true
656+ }
657+ ],
658+ "responses": {
659+ "200": {
660+ "description": "OK"
661+ },
662+ "404": {
663+ "description": "Not Found",
664+ "schema": {
665+ "$ref": "#/definitions/proto.Error"
666+ }
667+ },
668+ "500": {
669+ "description": "Internal Server Error",
670+ "schema": {
671+ "$ref": "#/definitions/proto.Error"
672+ }
673+ }
674+ }
675+ }
676+ },
677+ "/workspaces/{id}/agent/update": {
678+ "post": {
679+ "tags": [
680+ "agent"
681+ ],
682+ "summary": "Update agent",
683+ "parameters": [
684+ {
685+ "type": "string",
686+ "description": "Workspace ID",
687+ "name": "id",
688+ "in": "path",
689+ "required": true
690+ }
691+ ],
692+ "responses": {
693+ "200": {
694+ "description": "OK"
695+ },
696+ "404": {
697+ "description": "Not Found",
698+ "schema": {
699+ "$ref": "#/definitions/proto.Error"
700+ }
701+ },
702+ "500": {
703+ "description": "Internal Server Error",
704+ "schema": {
705+ "$ref": "#/definitions/proto.Error"
706+ }
707+ }
708+ }
709+ }
710+ },
711+ "/workspaces/{id}/config": {
712+ "get": {
713+ "produces": [
714+ "application/json"
715+ ],
716+ "tags": [
717+ "workspaces"
718+ ],
719+ "summary": "Get workspace config",
720+ "parameters": [
721+ {
722+ "type": "string",
723+ "description": "Workspace ID",
724+ "name": "id",
725+ "in": "path",
726+ "required": true
727+ }
728+ ],
729+ "responses": {
730+ "200": {
731+ "description": "OK",
732+ "schema": {
733+ "type": "object"
734+ }
735+ },
736+ "404": {
737+ "description": "Not Found",
738+ "schema": {
739+ "$ref": "#/definitions/proto.Error"
740+ }
741+ },
742+ "500": {
743+ "description": "Internal Server Error",
744+ "schema": {
745+ "$ref": "#/definitions/proto.Error"
746+ }
747+ }
748+ }
749+ }
750+ },
751+ "/workspaces/{id}/config/compact": {
752+ "post": {
753+ "consumes": [
754+ "application/json"
755+ ],
756+ "tags": [
757+ "config"
758+ ],
759+ "summary": "Set compact mode",
760+ "parameters": [
761+ {
762+ "type": "string",
763+ "description": "Workspace ID",
764+ "name": "id",
765+ "in": "path",
766+ "required": true
767+ },
768+ {
769+ "description": "Config compact request",
770+ "name": "request",
771+ "in": "body",
772+ "required": true,
773+ "schema": {
774+ "$ref": "#/definitions/proto.ConfigCompactRequest"
775+ }
776+ }
777+ ],
778+ "responses": {
779+ "200": {
780+ "description": "OK"
781+ },
782+ "400": {
783+ "description": "Bad Request",
784+ "schema": {
785+ "$ref": "#/definitions/proto.Error"
786+ }
787+ },
788+ "404": {
789+ "description": "Not Found",
790+ "schema": {
791+ "$ref": "#/definitions/proto.Error"
792+ }
793+ },
794+ "500": {
795+ "description": "Internal Server Error",
796+ "schema": {
797+ "$ref": "#/definitions/proto.Error"
798+ }
799+ }
800+ }
801+ }
802+ },
803+ "/workspaces/{id}/config/import-copilot": {
804+ "post": {
805+ "produces": [
806+ "application/json"
807+ ],
808+ "tags": [
809+ "config"
810+ ],
811+ "summary": "Import Copilot credentials",
812+ "parameters": [
813+ {
814+ "type": "string",
815+ "description": "Workspace ID",
816+ "name": "id",
817+ "in": "path",
818+ "required": true
819+ }
820+ ],
821+ "responses": {
822+ "200": {
823+ "description": "OK",
824+ "schema": {
825+ "$ref": "#/definitions/proto.ImportCopilotResponse"
826+ }
827+ },
828+ "404": {
829+ "description": "Not Found",
830+ "schema": {
831+ "$ref": "#/definitions/proto.Error"
832+ }
833+ },
834+ "500": {
835+ "description": "Internal Server Error",
836+ "schema": {
837+ "$ref": "#/definitions/proto.Error"
838+ }
839+ }
840+ }
841+ }
842+ },
843+ "/workspaces/{id}/config/model": {
844+ "post": {
845+ "consumes": [
846+ "application/json"
847+ ],
848+ "tags": [
849+ "config"
850+ ],
851+ "summary": "Set the preferred model",
852+ "parameters": [
853+ {
854+ "type": "string",
855+ "description": "Workspace ID",
856+ "name": "id",
857+ "in": "path",
858+ "required": true
859+ },
860+ {
861+ "description": "Config model request",
862+ "name": "request",
863+ "in": "body",
864+ "required": true,
865+ "schema": {
866+ "$ref": "#/definitions/proto.ConfigModelRequest"
867+ }
868+ }
869+ ],
870+ "responses": {
871+ "200": {
872+ "description": "OK"
873+ },
874+ "400": {
875+ "description": "Bad Request",
876+ "schema": {
877+ "$ref": "#/definitions/proto.Error"
878+ }
879+ },
880+ "404": {
881+ "description": "Not Found",
882+ "schema": {
883+ "$ref": "#/definitions/proto.Error"
884+ }
885+ },
886+ "500": {
887+ "description": "Internal Server Error",
888+ "schema": {
889+ "$ref": "#/definitions/proto.Error"
890+ }
891+ }
892+ }
893+ }
894+ },
895+ "/workspaces/{id}/config/provider-key": {
896+ "post": {
897+ "consumes": [
898+ "application/json"
899+ ],
900+ "tags": [
901+ "config"
902+ ],
903+ "summary": "Set provider API key",
904+ "parameters": [
905+ {
906+ "type": "string",
907+ "description": "Workspace ID",
908+ "name": "id",
909+ "in": "path",
910+ "required": true
911+ },
912+ {
913+ "description": "Config provider key request",
914+ "name": "request",
915+ "in": "body",
916+ "required": true,
917+ "schema": {
918+ "$ref": "#/definitions/proto.ConfigProviderKeyRequest"
919+ }
920+ }
921+ ],
922+ "responses": {
923+ "200": {
924+ "description": "OK"
925+ },
926+ "400": {
927+ "description": "Bad Request",
928+ "schema": {
929+ "$ref": "#/definitions/proto.Error"
930+ }
931+ },
932+ "404": {
933+ "description": "Not Found",
934+ "schema": {
935+ "$ref": "#/definitions/proto.Error"
936+ }
937+ },
938+ "500": {
939+ "description": "Internal Server Error",
940+ "schema": {
941+ "$ref": "#/definitions/proto.Error"
942+ }
943+ }
944+ }
945+ }
946+ },
947+ "/workspaces/{id}/config/refresh-oauth": {
948+ "post": {
949+ "consumes": [
950+ "application/json"
951+ ],
952+ "tags": [
953+ "config"
954+ ],
955+ "summary": "Refresh OAuth token",
956+ "parameters": [
957+ {
958+ "type": "string",
959+ "description": "Workspace ID",
960+ "name": "id",
961+ "in": "path",
962+ "required": true
963+ },
964+ {
965+ "description": "Refresh OAuth request",
966+ "name": "request",
967+ "in": "body",
968+ "required": true,
969+ "schema": {
970+ "$ref": "#/definitions/proto.ConfigRefreshOAuthRequest"
971+ }
972+ }
973+ ],
974+ "responses": {
975+ "200": {
976+ "description": "OK"
977+ },
978+ "400": {
979+ "description": "Bad Request",
980+ "schema": {
981+ "$ref": "#/definitions/proto.Error"
982+ }
983+ },
984+ "404": {
985+ "description": "Not Found",
986+ "schema": {
987+ "$ref": "#/definitions/proto.Error"
988+ }
989+ },
990+ "500": {
991+ "description": "Internal Server Error",
992+ "schema": {
993+ "$ref": "#/definitions/proto.Error"
994+ }
995+ }
996+ }
997+ }
998+ },
999+ "/workspaces/{id}/config/remove": {
1000+ "post": {
1001+ "consumes": [
1002+ "application/json"
1003+ ],
1004+ "tags": [
1005+ "config"
1006+ ],
1007+ "summary": "Remove a config field",
1008+ "parameters": [
1009+ {
1010+ "type": "string",
1011+ "description": "Workspace ID",
1012+ "name": "id",
1013+ "in": "path",
1014+ "required": true
1015+ },
1016+ {
1017+ "description": "Config remove request",
1018+ "name": "request",
1019+ "in": "body",
1020+ "required": true,
1021+ "schema": {
1022+ "$ref": "#/definitions/proto.ConfigRemoveRequest"
1023+ }
1024+ }
1025+ ],
1026+ "responses": {
1027+ "200": {
1028+ "description": "OK"
1029+ },
1030+ "400": {
1031+ "description": "Bad Request",
1032+ "schema": {
1033+ "$ref": "#/definitions/proto.Error"
1034+ }
1035+ },
1036+ "404": {
1037+ "description": "Not Found",
1038+ "schema": {
1039+ "$ref": "#/definitions/proto.Error"
1040+ }
1041+ },
1042+ "500": {
1043+ "description": "Internal Server Error",
1044+ "schema": {
1045+ "$ref": "#/definitions/proto.Error"
1046+ }
1047+ }
1048+ }
1049+ }
1050+ },
1051+ "/workspaces/{id}/config/set": {
1052+ "post": {
1053+ "consumes": [
1054+ "application/json"
1055+ ],
1056+ "tags": [
1057+ "config"
1058+ ],
1059+ "summary": "Set a config field",
1060+ "parameters": [
1061+ {
1062+ "type": "string",
1063+ "description": "Workspace ID",
1064+ "name": "id",
1065+ "in": "path",
1066+ "required": true
1067+ },
1068+ {
1069+ "description": "Config set request",
1070+ "name": "request",
1071+ "in": "body",
1072+ "required": true,
1073+ "schema": {
1074+ "$ref": "#/definitions/proto.ConfigSetRequest"
1075+ }
1076+ }
1077+ ],
1078+ "responses": {
1079+ "200": {
1080+ "description": "OK"
1081+ },
1082+ "400": {
1083+ "description": "Bad Request",
1084+ "schema": {
1085+ "$ref": "#/definitions/proto.Error"
1086+ }
1087+ },
1088+ "404": {
1089+ "description": "Not Found",
1090+ "schema": {
1091+ "$ref": "#/definitions/proto.Error"
1092+ }
1093+ },
1094+ "500": {
1095+ "description": "Internal Server Error",
1096+ "schema": {
1097+ "$ref": "#/definitions/proto.Error"
1098+ }
1099+ }
1100+ }
1101+ }
1102+ },
1103+ "/workspaces/{id}/events": {
1104+ "get": {
1105+ "produces": [
1106+ "text/event-stream"
1107+ ],
1108+ "tags": [
1109+ "workspaces"
1110+ ],
1111+ "summary": "Stream workspace events (SSE)",
1112+ "parameters": [
1113+ {
1114+ "type": "string",
1115+ "description": "Workspace ID",
1116+ "name": "id",
1117+ "in": "path",
1118+ "required": true
1119+ }
1120+ ],
1121+ "responses": {
1122+ "200": {
1123+ "description": "OK"
1124+ },
1125+ "404": {
1126+ "description": "Not Found",
1127+ "schema": {
1128+ "$ref": "#/definitions/proto.Error"
1129+ }
1130+ },
1131+ "500": {
1132+ "description": "Internal Server Error",
1133+ "schema": {
1134+ "$ref": "#/definitions/proto.Error"
1135+ }
1136+ }
1137+ }
1138+ }
1139+ },
1140+ "/workspaces/{id}/filetracker/lastread": {
1141+ "get": {
1142+ "produces": [
1143+ "application/json"
1144+ ],
1145+ "tags": [
1146+ "filetracker"
1147+ ],
1148+ "summary": "Get last read time for file",
1149+ "parameters": [
1150+ {
1151+ "type": "string",
1152+ "description": "Workspace ID",
1153+ "name": "id",
1154+ "in": "path",
1155+ "required": true
1156+ },
1157+ {
1158+ "type": "string",
1159+ "description": "Session ID",
1160+ "name": "session_id",
1161+ "in": "query"
1162+ },
1163+ {
1164+ "type": "string",
1165+ "description": "File path",
1166+ "name": "path",
1167+ "in": "query",
1168+ "required": true
1169+ }
1170+ ],
1171+ "responses": {
1172+ "200": {
1173+ "description": "OK",
1174+ "schema": {
1175+ "type": "object"
1176+ }
1177+ },
1178+ "404": {
1179+ "description": "Not Found",
1180+ "schema": {
1181+ "$ref": "#/definitions/proto.Error"
1182+ }
1183+ },
1184+ "500": {
1185+ "description": "Internal Server Error",
1186+ "schema": {
1187+ "$ref": "#/definitions/proto.Error"
1188+ }
1189+ }
1190+ }
1191+ }
1192+ },
1193+ "/workspaces/{id}/filetracker/read": {
1194+ "post": {
1195+ "consumes": [
1196+ "application/json"
1197+ ],
1198+ "tags": [
1199+ "filetracker"
1200+ ],
1201+ "summary": "Record file read",
1202+ "parameters": [
1203+ {
1204+ "type": "string",
1205+ "description": "Workspace ID",
1206+ "name": "id",
1207+ "in": "path",
1208+ "required": true
1209+ },
1210+ {
1211+ "description": "File tracker read request",
1212+ "name": "request",
1213+ "in": "body",
1214+ "required": true,
1215+ "schema": {
1216+ "$ref": "#/definitions/proto.FileTrackerReadRequest"
1217+ }
1218+ }
1219+ ],
1220+ "responses": {
1221+ "200": {
1222+ "description": "OK"
1223+ },
1224+ "400": {
1225+ "description": "Bad Request",
1226+ "schema": {
1227+ "$ref": "#/definitions/proto.Error"
1228+ }
1229+ },
1230+ "404": {
1231+ "description": "Not Found",
1232+ "schema": {
1233+ "$ref": "#/definitions/proto.Error"
1234+ }
1235+ },
1236+ "500": {
1237+ "description": "Internal Server Error",
1238+ "schema": {
1239+ "$ref": "#/definitions/proto.Error"
1240+ }
1241+ }
1242+ }
1243+ }
1244+ },
1245+ "/workspaces/{id}/lsps": {
1246+ "get": {
1247+ "produces": [
1248+ "application/json"
1249+ ],
1250+ "tags": [
1251+ "lsp"
1252+ ],
1253+ "summary": "List LSP clients",
1254+ "parameters": [
1255+ {
1256+ "type": "string",
1257+ "description": "Workspace ID",
1258+ "name": "id",
1259+ "in": "path",
1260+ "required": true
1261+ }
1262+ ],
1263+ "responses": {
1264+ "200": {
1265+ "description": "OK",
1266+ "schema": {
1267+ "type": "object",
1268+ "additionalProperties": {
1269+ "$ref": "#/definitions/proto.LSPClientInfo"
1270+ }
1271+ }
1272+ },
1273+ "404": {
1274+ "description": "Not Found",
1275+ "schema": {
1276+ "$ref": "#/definitions/proto.Error"
1277+ }
1278+ },
1279+ "500": {
1280+ "description": "Internal Server Error",
1281+ "schema": {
1282+ "$ref": "#/definitions/proto.Error"
1283+ }
1284+ }
1285+ }
1286+ }
1287+ },
1288+ "/workspaces/{id}/lsps/start": {
1289+ "post": {
1290+ "consumes": [
1291+ "application/json"
1292+ ],
1293+ "tags": [
1294+ "lsp"
1295+ ],
1296+ "summary": "Start LSP server",
1297+ "parameters": [
1298+ {
1299+ "type": "string",
1300+ "description": "Workspace ID",
1301+ "name": "id",
1302+ "in": "path",
1303+ "required": true
1304+ },
1305+ {
1306+ "description": "LSP start request",
1307+ "name": "request",
1308+ "in": "body",
1309+ "required": true,
1310+ "schema": {
1311+ "$ref": "#/definitions/proto.LSPStartRequest"
1312+ }
1313+ }
1314+ ],
1315+ "responses": {
1316+ "200": {
1317+ "description": "OK"
1318+ },
1319+ "400": {
1320+ "description": "Bad Request",
1321+ "schema": {
1322+ "$ref": "#/definitions/proto.Error"
1323+ }
1324+ },
1325+ "404": {
1326+ "description": "Not Found",
1327+ "schema": {
1328+ "$ref": "#/definitions/proto.Error"
1329+ }
1330+ },
1331+ "500": {
1332+ "description": "Internal Server Error",
1333+ "schema": {
1334+ "$ref": "#/definitions/proto.Error"
1335+ }
1336+ }
1337+ }
1338+ }
1339+ },
1340+ "/workspaces/{id}/lsps/stop": {
1341+ "post": {
1342+ "tags": [
1343+ "lsp"
1344+ ],
1345+ "summary": "Stop all LSP servers",
1346+ "parameters": [
1347+ {
1348+ "type": "string",
1349+ "description": "Workspace ID",
1350+ "name": "id",
1351+ "in": "path",
1352+ "required": true
1353+ }
1354+ ],
1355+ "responses": {
1356+ "200": {
1357+ "description": "OK"
1358+ },
1359+ "404": {
1360+ "description": "Not Found",
1361+ "schema": {
1362+ "$ref": "#/definitions/proto.Error"
1363+ }
1364+ },
1365+ "500": {
1366+ "description": "Internal Server Error",
1367+ "schema": {
1368+ "$ref": "#/definitions/proto.Error"
1369+ }
1370+ }
1371+ }
1372+ }
1373+ },
1374+ "/workspaces/{id}/lsps/{lsp}/diagnostics": {
1375+ "get": {
1376+ "produces": [
1377+ "application/json"
1378+ ],
1379+ "tags": [
1380+ "lsp"
1381+ ],
1382+ "summary": "Get LSP diagnostics",
1383+ "parameters": [
1384+ {
1385+ "type": "string",
1386+ "description": "Workspace ID",
1387+ "name": "id",
1388+ "in": "path",
1389+ "required": true
1390+ },
1391+ {
1392+ "type": "string",
1393+ "description": "LSP client name",
1394+ "name": "lsp",
1395+ "in": "path",
1396+ "required": true
1397+ }
1398+ ],
1399+ "responses": {
1400+ "200": {
1401+ "description": "OK",
1402+ "schema": {
1403+ "type": "object"
1404+ }
1405+ },
1406+ "404": {
1407+ "description": "Not Found",
1408+ "schema": {
1409+ "$ref": "#/definitions/proto.Error"
1410+ }
1411+ },
1412+ "500": {
1413+ "description": "Internal Server Error",
1414+ "schema": {
1415+ "$ref": "#/definitions/proto.Error"
1416+ }
1417+ }
1418+ }
1419+ }
1420+ },
1421+ "/workspaces/{id}/mcp/get-prompt": {
1422+ "post": {
1423+ "consumes": [
1424+ "application/json"
1425+ ],
1426+ "produces": [
1427+ "application/json"
1428+ ],
1429+ "tags": [
1430+ "mcp"
1431+ ],
1432+ "summary": "Get MCP prompt",
1433+ "parameters": [
1434+ {
1435+ "type": "string",
1436+ "description": "Workspace ID",
1437+ "name": "id",
1438+ "in": "path",
1439+ "required": true
1440+ },
1441+ {
1442+ "description": "MCP get prompt request",
1443+ "name": "request",
1444+ "in": "body",
1445+ "required": true,
1446+ "schema": {
1447+ "$ref": "#/definitions/proto.MCPGetPromptRequest"
1448+ }
1449+ }
1450+ ],
1451+ "responses": {
1452+ "200": {
1453+ "description": "OK",
1454+ "schema": {
1455+ "$ref": "#/definitions/proto.MCPGetPromptResponse"
1456+ }
1457+ },
1458+ "400": {
1459+ "description": "Bad Request",
1460+ "schema": {
1461+ "$ref": "#/definitions/proto.Error"
1462+ }
1463+ },
1464+ "404": {
1465+ "description": "Not Found",
1466+ "schema": {
1467+ "$ref": "#/definitions/proto.Error"
1468+ }
1469+ },
1470+ "500": {
1471+ "description": "Internal Server Error",
1472+ "schema": {
1473+ "$ref": "#/definitions/proto.Error"
1474+ }
1475+ }
1476+ }
1477+ }
1478+ },
1479+ "/workspaces/{id}/mcp/read-resource": {
1480+ "post": {
1481+ "consumes": [
1482+ "application/json"
1483+ ],
1484+ "produces": [
1485+ "application/json"
1486+ ],
1487+ "tags": [
1488+ "mcp"
1489+ ],
1490+ "summary": "Read MCP resource",
1491+ "parameters": [
1492+ {
1493+ "type": "string",
1494+ "description": "Workspace ID",
1495+ "name": "id",
1496+ "in": "path",
1497+ "required": true
1498+ },
1499+ {
1500+ "description": "MCP read resource request",
1501+ "name": "request",
1502+ "in": "body",
1503+ "required": true,
1504+ "schema": {
1505+ "$ref": "#/definitions/proto.MCPReadResourceRequest"
1506+ }
1507+ }
1508+ ],
1509+ "responses": {
1510+ "200": {
1511+ "description": "OK",
1512+ "schema": {
1513+ "type": "object"
1514+ }
1515+ },
1516+ "400": {
1517+ "description": "Bad Request",
1518+ "schema": {
1519+ "$ref": "#/definitions/proto.Error"
1520+ }
1521+ },
1522+ "404": {
1523+ "description": "Not Found",
1524+ "schema": {
1525+ "$ref": "#/definitions/proto.Error"
1526+ }
1527+ },
1528+ "500": {
1529+ "description": "Internal Server Error",
1530+ "schema": {
1531+ "$ref": "#/definitions/proto.Error"
1532+ }
1533+ }
1534+ }
1535+ }
1536+ },
1537+ "/workspaces/{id}/mcp/refresh-prompts": {
1538+ "post": {
1539+ "consumes": [
1540+ "application/json"
1541+ ],
1542+ "tags": [
1543+ "mcp"
1544+ ],
1545+ "summary": "Refresh MCP prompts",
1546+ "parameters": [
1547+ {
1548+ "type": "string",
1549+ "description": "Workspace ID",
1550+ "name": "id",
1551+ "in": "path",
1552+ "required": true
1553+ },
1554+ {
1555+ "description": "MCP name request",
1556+ "name": "request",
1557+ "in": "body",
1558+ "required": true,
1559+ "schema": {
1560+ "$ref": "#/definitions/proto.MCPNameRequest"
1561+ }
1562+ }
1563+ ],
1564+ "responses": {
1565+ "200": {
1566+ "description": "OK"
1567+ },
1568+ "400": {
1569+ "description": "Bad Request",
1570+ "schema": {
1571+ "$ref": "#/definitions/proto.Error"
1572+ }
1573+ },
1574+ "404": {
1575+ "description": "Not Found",
1576+ "schema": {
1577+ "$ref": "#/definitions/proto.Error"
1578+ }
1579+ },
1580+ "500": {
1581+ "description": "Internal Server Error",
1582+ "schema": {
1583+ "$ref": "#/definitions/proto.Error"
1584+ }
1585+ }
1586+ }
1587+ }
1588+ },
1589+ "/workspaces/{id}/mcp/refresh-resources": {
1590+ "post": {
1591+ "consumes": [
1592+ "application/json"
1593+ ],
1594+ "tags": [
1595+ "mcp"
1596+ ],
1597+ "summary": "Refresh MCP resources",
1598+ "parameters": [
1599+ {
1600+ "type": "string",
1601+ "description": "Workspace ID",
1602+ "name": "id",
1603+ "in": "path",
1604+ "required": true
1605+ },
1606+ {
1607+ "description": "MCP name request",
1608+ "name": "request",
1609+ "in": "body",
1610+ "required": true,
1611+ "schema": {
1612+ "$ref": "#/definitions/proto.MCPNameRequest"
1613+ }
1614+ }
1615+ ],
1616+ "responses": {
1617+ "200": {
1618+ "description": "OK"
1619+ },
1620+ "400": {
1621+ "description": "Bad Request",
1622+ "schema": {
1623+ "$ref": "#/definitions/proto.Error"
1624+ }
1625+ },
1626+ "404": {
1627+ "description": "Not Found",
1628+ "schema": {
1629+ "$ref": "#/definitions/proto.Error"
1630+ }
1631+ },
1632+ "500": {
1633+ "description": "Internal Server Error",
1634+ "schema": {
1635+ "$ref": "#/definitions/proto.Error"
1636+ }
1637+ }
1638+ }
1639+ }
1640+ },
1641+ "/workspaces/{id}/mcp/refresh-tools": {
1642+ "post": {
1643+ "consumes": [
1644+ "application/json"
1645+ ],
1646+ "tags": [
1647+ "mcp"
1648+ ],
1649+ "summary": "Refresh MCP tools",
1650+ "parameters": [
1651+ {
1652+ "type": "string",
1653+ "description": "Workspace ID",
1654+ "name": "id",
1655+ "in": "path",
1656+ "required": true
1657+ },
1658+ {
1659+ "description": "MCP name request",
1660+ "name": "request",
1661+ "in": "body",
1662+ "required": true,
1663+ "schema": {
1664+ "$ref": "#/definitions/proto.MCPNameRequest"
1665+ }
1666+ }
1667+ ],
1668+ "responses": {
1669+ "200": {
1670+ "description": "OK"
1671+ },
1672+ "400": {
1673+ "description": "Bad Request",
1674+ "schema": {
1675+ "$ref": "#/definitions/proto.Error"
1676+ }
1677+ },
1678+ "404": {
1679+ "description": "Not Found",
1680+ "schema": {
1681+ "$ref": "#/definitions/proto.Error"
1682+ }
1683+ },
1684+ "500": {
1685+ "description": "Internal Server Error",
1686+ "schema": {
1687+ "$ref": "#/definitions/proto.Error"
1688+ }
1689+ }
1690+ }
1691+ }
1692+ },
1693+ "/workspaces/{id}/mcp/states": {
1694+ "get": {
1695+ "produces": [
1696+ "application/json"
1697+ ],
1698+ "tags": [
1699+ "mcp"
1700+ ],
1701+ "summary": "Get MCP client states",
1702+ "parameters": [
1703+ {
1704+ "type": "string",
1705+ "description": "Workspace ID",
1706+ "name": "id",
1707+ "in": "path",
1708+ "required": true
1709+ }
1710+ ],
1711+ "responses": {
1712+ "200": {
1713+ "description": "OK",
1714+ "schema": {
1715+ "type": "object",
1716+ "additionalProperties": {
1717+ "$ref": "#/definitions/proto.MCPClientInfo"
1718+ }
1719+ }
1720+ },
1721+ "404": {
1722+ "description": "Not Found",
1723+ "schema": {
1724+ "$ref": "#/definitions/proto.Error"
1725+ }
1726+ },
1727+ "500": {
1728+ "description": "Internal Server Error",
1729+ "schema": {
1730+ "$ref": "#/definitions/proto.Error"
1731+ }
1732+ }
1733+ }
1734+ }
1735+ },
1736+ "/workspaces/{id}/messages/user": {
1737+ "get": {
1738+ "produces": [
1739+ "application/json"
1740+ ],
1741+ "tags": [
1742+ "workspaces"
1743+ ],
1744+ "summary": "Get all user messages for workspace",
1745+ "parameters": [
1746+ {
1747+ "type": "string",
1748+ "description": "Workspace ID",
1749+ "name": "id",
1750+ "in": "path",
1751+ "required": true
1752+ }
1753+ ],
1754+ "responses": {
1755+ "200": {
1756+ "description": "OK",
1757+ "schema": {
1758+ "type": "array",
1759+ "items": {
1760+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
1761+ }
1762+ }
1763+ },
1764+ "404": {
1765+ "description": "Not Found",
1766+ "schema": {
1767+ "$ref": "#/definitions/proto.Error"
1768+ }
1769+ },
1770+ "500": {
1771+ "description": "Internal Server Error",
1772+ "schema": {
1773+ "$ref": "#/definitions/proto.Error"
1774+ }
1775+ }
1776+ }
1777+ }
1778+ },
1779+ "/workspaces/{id}/permissions/grant": {
1780+ "post": {
1781+ "consumes": [
1782+ "application/json"
1783+ ],
1784+ "tags": [
1785+ "permissions"
1786+ ],
1787+ "summary": "Grant permission",
1788+ "parameters": [
1789+ {
1790+ "type": "string",
1791+ "description": "Workspace ID",
1792+ "name": "id",
1793+ "in": "path",
1794+ "required": true
1795+ },
1796+ {
1797+ "description": "Permission grant",
1798+ "name": "request",
1799+ "in": "body",
1800+ "required": true,
1801+ "schema": {
1802+ "$ref": "#/definitions/proto.PermissionGrant"
1803+ }
1804+ }
1805+ ],
1806+ "responses": {
1807+ "200": {
1808+ "description": "OK"
1809+ },
1810+ "400": {
1811+ "description": "Bad Request",
1812+ "schema": {
1813+ "$ref": "#/definitions/proto.Error"
1814+ }
1815+ },
1816+ "404": {
1817+ "description": "Not Found",
1818+ "schema": {
1819+ "$ref": "#/definitions/proto.Error"
1820+ }
1821+ },
1822+ "500": {
1823+ "description": "Internal Server Error",
1824+ "schema": {
1825+ "$ref": "#/definitions/proto.Error"
1826+ }
1827+ }
1828+ }
1829+ }
1830+ },
1831+ "/workspaces/{id}/permissions/skip": {
1832+ "get": {
1833+ "produces": [
1834+ "application/json"
1835+ ],
1836+ "tags": [
1837+ "permissions"
1838+ ],
1839+ "summary": "Get skip permissions status",
1840+ "parameters": [
1841+ {
1842+ "type": "string",
1843+ "description": "Workspace ID",
1844+ "name": "id",
1845+ "in": "path",
1846+ "required": true
1847+ }
1848+ ],
1849+ "responses": {
1850+ "200": {
1851+ "description": "OK",
1852+ "schema": {
1853+ "$ref": "#/definitions/proto.PermissionSkipRequest"
1854+ }
1855+ },
1856+ "404": {
1857+ "description": "Not Found",
1858+ "schema": {
1859+ "$ref": "#/definitions/proto.Error"
1860+ }
1861+ },
1862+ "500": {
1863+ "description": "Internal Server Error",
1864+ "schema": {
1865+ "$ref": "#/definitions/proto.Error"
1866+ }
1867+ }
1868+ }
1869+ },
1870+ "post": {
1871+ "consumes": [
1872+ "application/json"
1873+ ],
1874+ "tags": [
1875+ "permissions"
1876+ ],
1877+ "summary": "Set skip permissions",
1878+ "parameters": [
1879+ {
1880+ "type": "string",
1881+ "description": "Workspace ID",
1882+ "name": "id",
1883+ "in": "path",
1884+ "required": true
1885+ },
1886+ {
1887+ "description": "Permission skip request",
1888+ "name": "request",
1889+ "in": "body",
1890+ "required": true,
1891+ "schema": {
1892+ "$ref": "#/definitions/proto.PermissionSkipRequest"
1893+ }
1894+ }
1895+ ],
1896+ "responses": {
1897+ "200": {
1898+ "description": "OK"
1899+ },
1900+ "400": {
1901+ "description": "Bad Request",
1902+ "schema": {
1903+ "$ref": "#/definitions/proto.Error"
1904+ }
1905+ },
1906+ "404": {
1907+ "description": "Not Found",
1908+ "schema": {
1909+ "$ref": "#/definitions/proto.Error"
1910+ }
1911+ },
1912+ "500": {
1913+ "description": "Internal Server Error",
1914+ "schema": {
1915+ "$ref": "#/definitions/proto.Error"
1916+ }
1917+ }
1918+ }
1919+ }
1920+ },
1921+ "/workspaces/{id}/project/init": {
1922+ "post": {
1923+ "tags": [
1924+ "project"
1925+ ],
1926+ "summary": "Mark project as initialized",
1927+ "parameters": [
1928+ {
1929+ "type": "string",
1930+ "description": "Workspace ID",
1931+ "name": "id",
1932+ "in": "path",
1933+ "required": true
1934+ }
1935+ ],
1936+ "responses": {
1937+ "200": {
1938+ "description": "OK"
1939+ },
1940+ "404": {
1941+ "description": "Not Found",
1942+ "schema": {
1943+ "$ref": "#/definitions/proto.Error"
1944+ }
1945+ },
1946+ "500": {
1947+ "description": "Internal Server Error",
1948+ "schema": {
1949+ "$ref": "#/definitions/proto.Error"
1950+ }
1951+ }
1952+ }
1953+ }
1954+ },
1955+ "/workspaces/{id}/project/init-prompt": {
1956+ "get": {
1957+ "produces": [
1958+ "application/json"
1959+ ],
1960+ "tags": [
1961+ "project"
1962+ ],
1963+ "summary": "Get project initialization prompt",
1964+ "parameters": [
1965+ {
1966+ "type": "string",
1967+ "description": "Workspace ID",
1968+ "name": "id",
1969+ "in": "path",
1970+ "required": true
1971+ }
1972+ ],
1973+ "responses": {
1974+ "200": {
1975+ "description": "OK",
1976+ "schema": {
1977+ "$ref": "#/definitions/proto.ProjectInitPromptResponse"
1978+ }
1979+ },
1980+ "404": {
1981+ "description": "Not Found",
1982+ "schema": {
1983+ "$ref": "#/definitions/proto.Error"
1984+ }
1985+ },
1986+ "500": {
1987+ "description": "Internal Server Error",
1988+ "schema": {
1989+ "$ref": "#/definitions/proto.Error"
1990+ }
1991+ }
1992+ }
1993+ }
1994+ },
1995+ "/workspaces/{id}/project/needs-init": {
1996+ "get": {
1997+ "produces": [
1998+ "application/json"
1999+ ],
2000+ "tags": [
2001+ "project"
2002+ ],
2003+ "summary": "Check if project needs initialization",
2004+ "parameters": [
2005+ {
2006+ "type": "string",
2007+ "description": "Workspace ID",
2008+ "name": "id",
2009+ "in": "path",
2010+ "required": true
2011+ }
2012+ ],
2013+ "responses": {
2014+ "200": {
2015+ "description": "OK",
2016+ "schema": {
2017+ "$ref": "#/definitions/proto.ProjectNeedsInitResponse"
2018+ }
2019+ },
2020+ "404": {
2021+ "description": "Not Found",
2022+ "schema": {
2023+ "$ref": "#/definitions/proto.Error"
2024+ }
2025+ },
2026+ "500": {
2027+ "description": "Internal Server Error",
2028+ "schema": {
2029+ "$ref": "#/definitions/proto.Error"
2030+ }
2031+ }
2032+ }
2033+ }
2034+ },
2035+ "/workspaces/{id}/providers": {
2036+ "get": {
2037+ "produces": [
2038+ "application/json"
2039+ ],
2040+ "tags": [
2041+ "workspaces"
2042+ ],
2043+ "summary": "Get workspace providers",
2044+ "parameters": [
2045+ {
2046+ "type": "string",
2047+ "description": "Workspace ID",
2048+ "name": "id",
2049+ "in": "path",
2050+ "required": true
2051+ }
2052+ ],
2053+ "responses": {
2054+ "200": {
2055+ "description": "OK",
2056+ "schema": {
2057+ "type": "object"
2058+ }
2059+ },
2060+ "404": {
2061+ "description": "Not Found",
2062+ "schema": {
2063+ "$ref": "#/definitions/proto.Error"
2064+ }
2065+ },
2066+ "500": {
2067+ "description": "Internal Server Error",
2068+ "schema": {
2069+ "$ref": "#/definitions/proto.Error"
2070+ }
2071+ }
2072+ }
2073+ }
2074+ },
2075+ "/workspaces/{id}/sessions": {
2076+ "get": {
2077+ "produces": [
2078+ "application/json"
2079+ ],
2080+ "tags": [
2081+ "sessions"
2082+ ],
2083+ "summary": "List sessions",
2084+ "parameters": [
2085+ {
2086+ "type": "string",
2087+ "description": "Workspace ID",
2088+ "name": "id",
2089+ "in": "path",
2090+ "required": true
2091+ }
2092+ ],
2093+ "responses": {
2094+ "200": {
2095+ "description": "OK",
2096+ "schema": {
2097+ "type": "array",
2098+ "items": {
2099+ "$ref": "#/definitions/proto.Session"
2100+ }
2101+ }
2102+ },
2103+ "404": {
2104+ "description": "Not Found",
2105+ "schema": {
2106+ "$ref": "#/definitions/proto.Error"
2107+ }
2108+ },
2109+ "500": {
2110+ "description": "Internal Server Error",
2111+ "schema": {
2112+ "$ref": "#/definitions/proto.Error"
2113+ }
2114+ }
2115+ }
2116+ },
2117+ "post": {
2118+ "consumes": [
2119+ "application/json"
2120+ ],
2121+ "produces": [
2122+ "application/json"
2123+ ],
2124+ "tags": [
2125+ "sessions"
2126+ ],
2127+ "summary": "Create session",
2128+ "parameters": [
2129+ {
2130+ "type": "string",
2131+ "description": "Workspace ID",
2132+ "name": "id",
2133+ "in": "path",
2134+ "required": true
2135+ },
2136+ {
2137+ "description": "Session creation params (title)",
2138+ "name": "request",
2139+ "in": "body",
2140+ "required": true,
2141+ "schema": {
2142+ "$ref": "#/definitions/proto.Session"
2143+ }
2144+ }
2145+ ],
2146+ "responses": {
2147+ "200": {
2148+ "description": "OK",
2149+ "schema": {
2150+ "$ref": "#/definitions/proto.Session"
2151+ }
2152+ },
2153+ "400": {
2154+ "description": "Bad Request",
2155+ "schema": {
2156+ "$ref": "#/definitions/proto.Error"
2157+ }
2158+ },
2159+ "404": {
2160+ "description": "Not Found",
2161+ "schema": {
2162+ "$ref": "#/definitions/proto.Error"
2163+ }
2164+ },
2165+ "500": {
2166+ "description": "Internal Server Error",
2167+ "schema": {
2168+ "$ref": "#/definitions/proto.Error"
2169+ }
2170+ }
2171+ }
2172+ }
2173+ },
2174+ "/workspaces/{id}/sessions/{sid}": {
2175+ "get": {
2176+ "produces": [
2177+ "application/json"
2178+ ],
2179+ "tags": [
2180+ "sessions"
2181+ ],
2182+ "summary": "Get session",
2183+ "parameters": [
2184+ {
2185+ "type": "string",
2186+ "description": "Workspace ID",
2187+ "name": "id",
2188+ "in": "path",
2189+ "required": true
2190+ },
2191+ {
2192+ "type": "string",
2193+ "description": "Session ID",
2194+ "name": "sid",
2195+ "in": "path",
2196+ "required": true
2197+ }
2198+ ],
2199+ "responses": {
2200+ "200": {
2201+ "description": "OK",
2202+ "schema": {
2203+ "$ref": "#/definitions/proto.Session"
2204+ }
2205+ },
2206+ "404": {
2207+ "description": "Not Found",
2208+ "schema": {
2209+ "$ref": "#/definitions/proto.Error"
2210+ }
2211+ },
2212+ "500": {
2213+ "description": "Internal Server Error",
2214+ "schema": {
2215+ "$ref": "#/definitions/proto.Error"
2216+ }
2217+ }
2218+ }
2219+ },
2220+ "put": {
2221+ "consumes": [
2222+ "application/json"
2223+ ],
2224+ "produces": [
2225+ "application/json"
2226+ ],
2227+ "tags": [
2228+ "sessions"
2229+ ],
2230+ "summary": "Update session",
2231+ "parameters": [
2232+ {
2233+ "type": "string",
2234+ "description": "Workspace ID",
2235+ "name": "id",
2236+ "in": "path",
2237+ "required": true
2238+ },
2239+ {
2240+ "type": "string",
2241+ "description": "Session ID",
2242+ "name": "sid",
2243+ "in": "path",
2244+ "required": true
2245+ },
2246+ {
2247+ "description": "Updated session",
2248+ "name": "request",
2249+ "in": "body",
2250+ "required": true,
2251+ "schema": {
2252+ "$ref": "#/definitions/proto.Session"
2253+ }
2254+ }
2255+ ],
2256+ "responses": {
2257+ "200": {
2258+ "description": "OK",
2259+ "schema": {
2260+ "$ref": "#/definitions/proto.Session"
2261+ }
2262+ },
2263+ "400": {
2264+ "description": "Bad Request",
2265+ "schema": {
2266+ "$ref": "#/definitions/proto.Error"
2267+ }
2268+ },
2269+ "404": {
2270+ "description": "Not Found",
2271+ "schema": {
2272+ "$ref": "#/definitions/proto.Error"
2273+ }
2274+ },
2275+ "500": {
2276+ "description": "Internal Server Error",
2277+ "schema": {
2278+ "$ref": "#/definitions/proto.Error"
2279+ }
2280+ }
2281+ }
2282+ },
2283+ "delete": {
2284+ "tags": [
2285+ "sessions"
2286+ ],
2287+ "summary": "Delete session",
2288+ "parameters": [
2289+ {
2290+ "type": "string",
2291+ "description": "Workspace ID",
2292+ "name": "id",
2293+ "in": "path",
2294+ "required": true
2295+ },
2296+ {
2297+ "type": "string",
2298+ "description": "Session ID",
2299+ "name": "sid",
2300+ "in": "path",
2301+ "required": true
2302+ }
2303+ ],
2304+ "responses": {
2305+ "200": {
2306+ "description": "OK"
2307+ },
2308+ "404": {
2309+ "description": "Not Found",
2310+ "schema": {
2311+ "$ref": "#/definitions/proto.Error"
2312+ }
2313+ },
2314+ "500": {
2315+ "description": "Internal Server Error",
2316+ "schema": {
2317+ "$ref": "#/definitions/proto.Error"
2318+ }
2319+ }
2320+ }
2321+ }
2322+ },
2323+ "/workspaces/{id}/sessions/{sid}/filetracker/files": {
2324+ "get": {
2325+ "produces": [
2326+ "application/json"
2327+ ],
2328+ "tags": [
2329+ "filetracker"
2330+ ],
2331+ "summary": "List tracked files for session",
2332+ "parameters": [
2333+ {
2334+ "type": "string",
2335+ "description": "Workspace ID",
2336+ "name": "id",
2337+ "in": "path",
2338+ "required": true
2339+ },
2340+ {
2341+ "type": "string",
2342+ "description": "Session ID",
2343+ "name": "sid",
2344+ "in": "path",
2345+ "required": true
2346+ }
2347+ ],
2348+ "responses": {
2349+ "200": {
2350+ "description": "OK",
2351+ "schema": {
2352+ "type": "array",
2353+ "items": {
2354+ "type": "string"
2355+ }
2356+ }
2357+ },
2358+ "404": {
2359+ "description": "Not Found",
2360+ "schema": {
2361+ "$ref": "#/definitions/proto.Error"
2362+ }
2363+ },
2364+ "500": {
2365+ "description": "Internal Server Error",
2366+ "schema": {
2367+ "$ref": "#/definitions/proto.Error"
2368+ }
2369+ }
2370+ }
2371+ }
2372+ },
2373+ "/workspaces/{id}/sessions/{sid}/history": {
2374+ "get": {
2375+ "produces": [
2376+ "application/json"
2377+ ],
2378+ "tags": [
2379+ "sessions"
2380+ ],
2381+ "summary": "Get session history",
2382+ "parameters": [
2383+ {
2384+ "type": "string",
2385+ "description": "Workspace ID",
2386+ "name": "id",
2387+ "in": "path",
2388+ "required": true
2389+ },
2390+ {
2391+ "type": "string",
2392+ "description": "Session ID",
2393+ "name": "sid",
2394+ "in": "path",
2395+ "required": true
2396+ }
2397+ ],
2398+ "responses": {
2399+ "200": {
2400+ "description": "OK",
2401+ "schema": {
2402+ "type": "array",
2403+ "items": {
2404+ "$ref": "#/definitions/proto.File"
2405+ }
2406+ }
2407+ },
2408+ "404": {
2409+ "description": "Not Found",
2410+ "schema": {
2411+ "$ref": "#/definitions/proto.Error"
2412+ }
2413+ },
2414+ "500": {
2415+ "description": "Internal Server Error",
2416+ "schema": {
2417+ "$ref": "#/definitions/proto.Error"
2418+ }
2419+ }
2420+ }
2421+ }
2422+ },
2423+ "/workspaces/{id}/sessions/{sid}/messages": {
2424+ "get": {
2425+ "produces": [
2426+ "application/json"
2427+ ],
2428+ "tags": [
2429+ "sessions"
2430+ ],
2431+ "summary": "Get session messages",
2432+ "parameters": [
2433+ {
2434+ "type": "string",
2435+ "description": "Workspace ID",
2436+ "name": "id",
2437+ "in": "path",
2438+ "required": true
2439+ },
2440+ {
2441+ "type": "string",
2442+ "description": "Session ID",
2443+ "name": "sid",
2444+ "in": "path",
2445+ "required": true
2446+ }
2447+ ],
2448+ "responses": {
2449+ "200": {
2450+ "description": "OK",
2451+ "schema": {
2452+ "type": "array",
2453+ "items": {
2454+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
2455+ }
2456+ }
2457+ },
2458+ "404": {
2459+ "description": "Not Found",
2460+ "schema": {
2461+ "$ref": "#/definitions/proto.Error"
2462+ }
2463+ },
2464+ "500": {
2465+ "description": "Internal Server Error",
2466+ "schema": {
2467+ "$ref": "#/definitions/proto.Error"
2468+ }
2469+ }
2470+ }
2471+ }
2472+ },
2473+ "/workspaces/{id}/sessions/{sid}/messages/user": {
2474+ "get": {
2475+ "produces": [
2476+ "application/json"
2477+ ],
2478+ "tags": [
2479+ "sessions"
2480+ ],
2481+ "summary": "Get user messages for session",
2482+ "parameters": [
2483+ {
2484+ "type": "string",
2485+ "description": "Workspace ID",
2486+ "name": "id",
2487+ "in": "path",
2488+ "required": true
2489+ },
2490+ {
2491+ "type": "string",
2492+ "description": "Session ID",
2493+ "name": "sid",
2494+ "in": "path",
2495+ "required": true
2496+ }
2497+ ],
2498+ "responses": {
2499+ "200": {
2500+ "description": "OK",
2501+ "schema": {
2502+ "type": "array",
2503+ "items": {
2504+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
2505+ }
2506+ }
2507+ },
2508+ "404": {
2509+ "description": "Not Found",
2510+ "schema": {
2511+ "$ref": "#/definitions/proto.Error"
2512+ }
2513+ },
2514+ "500": {
2515+ "description": "Internal Server Error",
2516+ "schema": {
2517+ "$ref": "#/definitions/proto.Error"
2518+ }
2519+ }
2520+ }
2521+ }
2522+ }
2523+ },
2524+ "definitions": {
2525+ "catwalk.Model": {
2526+ "type": "object",
2527+ "properties": {
2528+ "can_reason": {
2529+ "type": "boolean"
2530+ },
2531+ "context_window": {
2532+ "type": "integer"
2533+ },
2534+ "cost_per_1m_in": {
2535+ "type": "number"
2536+ },
2537+ "cost_per_1m_in_cached": {
2538+ "type": "number"
2539+ },
2540+ "cost_per_1m_out": {
2541+ "type": "number"
2542+ },
2543+ "cost_per_1m_out_cached": {
2544+ "type": "number"
2545+ },
2546+ "default_max_tokens": {
2547+ "type": "integer"
2548+ },
2549+ "default_reasoning_effort": {
2550+ "type": "string"
2551+ },
2552+ "id": {
2553+ "type": "string"
2554+ },
2555+ "name": {
2556+ "type": "string"
2557+ },
2558+ "options": {
2559+ "$ref": "#/definitions/catwalk.ModelOptions"
2560+ },
2561+ "reasoning_levels": {
2562+ "type": "array",
2563+ "items": {
2564+ "type": "string"
2565+ }
2566+ },
2567+ "supports_attachments": {
2568+ "type": "boolean"
2569+ }
2570+ }
2571+ },
2572+ "catwalk.ModelOptions": {
2573+ "type": "object",
2574+ "properties": {
2575+ "frequency_penalty": {
2576+ "type": "number"
2577+ },
2578+ "presence_penalty": {
2579+ "type": "number"
2580+ },
2581+ "provider_options": {
2582+ "type": "object",
2583+ "additionalProperties": {}
2584+ },
2585+ "temperature": {
2586+ "type": "number"
2587+ },
2588+ "top_k": {
2589+ "type": "integer"
2590+ },
2591+ "top_p": {
2592+ "type": "number"
2593+ }
2594+ }
2595+ },
2596+ "config.Attribution": {
2597+ "type": "object",
2598+ "properties": {
2599+ "co_authored_by": {
2600+ "type": "boolean"
2601+ },
2602+ "generated_with": {
2603+ "type": "boolean"
2604+ },
2605+ "trailer_style": {
2606+ "$ref": "#/definitions/config.TrailerStyle"
2607+ }
2608+ }
2609+ },
2610+ "config.Completions": {
2611+ "type": "object",
2612+ "properties": {
2613+ "max_depth": {
2614+ "type": "integer"
2615+ },
2616+ "max_items": {
2617+ "type": "integer"
2618+ }
2619+ }
2620+ },
2621+ "config.LSPConfig": {
2622+ "type": "object",
2623+ "properties": {
2624+ "args": {
2625+ "type": "array",
2626+ "items": {
2627+ "type": "string"
2628+ }
2629+ },
2630+ "command": {
2631+ "type": "string"
2632+ },
2633+ "disabled": {
2634+ "type": "boolean"
2635+ },
2636+ "env": {
2637+ "type": "object",
2638+ "additionalProperties": {
2639+ "type": "string"
2640+ }
2641+ },
2642+ "filetypes": {
2643+ "type": "array",
2644+ "items": {
2645+ "type": "string"
2646+ }
2647+ },
2648+ "init_options": {
2649+ "type": "object",
2650+ "additionalProperties": {}
2651+ },
2652+ "options": {
2653+ "type": "object",
2654+ "additionalProperties": {}
2655+ },
2656+ "root_markers": {
2657+ "type": "array",
2658+ "items": {
2659+ "type": "string"
2660+ }
2661+ },
2662+ "timeout": {
2663+ "type": "integer"
2664+ }
2665+ }
2666+ },
2667+ "config.LSPs": {
2668+ "type": "object",
2669+ "additionalProperties": {
2670+ "$ref": "#/definitions/config.LSPConfig"
2671+ }
2672+ },
2673+ "config.MCPConfig": {
2674+ "type": "object",
2675+ "properties": {
2676+ "args": {
2677+ "type": "array",
2678+ "items": {
2679+ "type": "string"
2680+ }
2681+ },
2682+ "command": {
2683+ "type": "string"
2684+ },
2685+ "disabled": {
2686+ "type": "boolean"
2687+ },
2688+ "disabled_tools": {
2689+ "type": "array",
2690+ "items": {
2691+ "type": "string"
2692+ }
2693+ },
2694+ "env": {
2695+ "type": "object",
2696+ "additionalProperties": {
2697+ "type": "string"
2698+ }
2699+ },
2700+ "headers": {
2701+ "description": "TODO: maybe make it possible to get the value from the env",
2702+ "type": "object",
2703+ "additionalProperties": {
2704+ "type": "string"
2705+ }
2706+ },
2707+ "timeout": {
2708+ "type": "integer"
2709+ },
2710+ "type": {
2711+ "$ref": "#/definitions/config.MCPType"
2712+ },
2713+ "url": {
2714+ "type": "string"
2715+ }
2716+ }
2717+ },
2718+ "config.MCPType": {
2719+ "type": "string",
2720+ "enum": [
2721+ "stdio",
2722+ "sse",
2723+ "http"
2724+ ],
2725+ "x-enum-varnames": [
2726+ "MCPStdio",
2727+ "MCPSSE",
2728+ "MCPHttp"
2729+ ]
2730+ },
2731+ "config.MCPs": {
2732+ "type": "object",
2733+ "additionalProperties": {
2734+ "$ref": "#/definitions/config.MCPConfig"
2735+ }
2736+ },
2737+ "config.Permissions": {
2738+ "type": "object",
2739+ "properties": {
2740+ "allowed_tools": {
2741+ "type": "array",
2742+ "items": {
2743+ "type": "string"
2744+ }
2745+ }
2746+ }
2747+ },
2748+ "config.Scope": {
2749+ "type": "integer",
2750+ "enum": [
2751+ 0,
2752+ 1
2753+ ],
2754+ "x-enum-varnames": [
2755+ "ScopeGlobal",
2756+ "ScopeWorkspace"
2757+ ]
2758+ },
2759+ "config.SelectedModel": {
2760+ "type": "object",
2761+ "properties": {
2762+ "frequency_penalty": {
2763+ "type": "number"
2764+ },
2765+ "max_tokens": {
2766+ "description": "Overrides the default model configuration.",
2767+ "type": "integer"
2768+ },
2769+ "model": {
2770+ "description": "The model id as used by the provider API.\nRequired.",
2771+ "type": "string"
2772+ },
2773+ "presence_penalty": {
2774+ "type": "number"
2775+ },
2776+ "provider": {
2777+ "description": "The model provider, same as the key/id used in the providers config.\nRequired.",
2778+ "type": "string"
2779+ },
2780+ "provider_options": {
2781+ "description": "Override provider specific options.",
2782+ "type": "object",
2783+ "additionalProperties": {}
2784+ },
2785+ "reasoning_effort": {
2786+ "description": "Only used by models that use the openai provider and need this set.",
2787+ "type": "string"
2788+ },
2789+ "temperature": {
2790+ "type": "number"
2791+ },
2792+ "think": {
2793+ "description": "Used by anthropic models that can reason to indicate if the model should think.",
2794+ "type": "boolean"
2795+ },
2796+ "top_k": {
2797+ "type": "integer"
2798+ },
2799+ "top_p": {
2800+ "type": "number"
2801+ }
2802+ }
2803+ },
2804+ "config.SelectedModelType": {
2805+ "type": "string",
2806+ "enum": [
2807+ "large",
2808+ "small"
2809+ ],
2810+ "x-enum-varnames": [
2811+ "SelectedModelTypeLarge",
2812+ "SelectedModelTypeSmall"
2813+ ]
2814+ },
2815+ "config.TUIOptions": {
2816+ "type": "object",
2817+ "properties": {
2818+ "compact_mode": {
2819+ "type": "boolean"
2820+ },
2821+ "completions": {
2822+ "$ref": "#/definitions/config.Completions"
2823+ },
2824+ "diff_mode": {
2825+ "type": "string"
2826+ },
2827+ "transparent": {
2828+ "type": "boolean"
2829+ }
2830+ }
2831+ },
2832+ "config.ToolGrep": {
2833+ "type": "object",
2834+ "properties": {
2835+ "timeout": {
2836+ "$ref": "#/definitions/time.Duration"
2837+ }
2838+ }
2839+ },
2840+ "config.ToolLs": {
2841+ "type": "object",
2842+ "properties": {
2843+ "max_depth": {
2844+ "type": "integer"
2845+ },
2846+ "max_items": {
2847+ "type": "integer"
2848+ }
2849+ }
2850+ },
2851+ "config.Tools": {
2852+ "type": "object",
2853+ "properties": {
2854+ "grep": {
2855+ "$ref": "#/definitions/config.ToolGrep"
2856+ },
2857+ "ls": {
2858+ "$ref": "#/definitions/config.ToolLs"
2859+ }
2860+ }
2861+ },
2862+ "config.TrailerStyle": {
2863+ "type": "string",
2864+ "enum": [
2865+ "none",
2866+ "co-authored-by",
2867+ "assisted-by"
2868+ ],
2869+ "x-enum-varnames": [
2870+ "TrailerStyleNone",
2871+ "TrailerStyleCoAuthoredBy",
2872+ "TrailerStyleAssistedBy"
2873+ ]
2874+ },
2875+ "csync.Map-string-config_ProviderConfig": {
2876+ "type": "object"
2877+ },
2878+ "github_com_charmbracelet_crush_internal_config.Config": {
2879+ "type": "object",
2880+ "properties": {
2881+ "$schema": {
2882+ "type": "string"
2883+ },
2884+ "lsp": {
2885+ "$ref": "#/definitions/config.LSPs"
2886+ },
2887+ "mcp": {
2888+ "$ref": "#/definitions/config.MCPs"
2889+ },
2890+ "models": {
2891+ "description": "We currently only support large/small as values here.",
2892+ "type": "object",
2893+ "additionalProperties": {
2894+ "$ref": "#/definitions/config.SelectedModel"
2895+ }
2896+ },
2897+ "options": {
2898+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_config.Options"
2899+ },
2900+ "permissions": {
2901+ "$ref": "#/definitions/config.Permissions"
2902+ },
2903+ "providers": {
2904+ "description": "The providers that are configured",
2905+ "allOf": [
2906+ {
2907+ "$ref": "#/definitions/csync.Map-string-config_ProviderConfig"
2908+ }
2909+ ]
2910+ },
2911+ "recent_models": {
2912+ "description": "Recently used models stored in the data directory config.",
2913+ "type": "object",
2914+ "additionalProperties": {
2915+ "type": "array",
2916+ "items": {
2917+ "$ref": "#/definitions/config.SelectedModel"
2918+ }
2919+ }
2920+ },
2921+ "tools": {
2922+ "$ref": "#/definitions/config.Tools"
2923+ }
2924+ }
2925+ },
2926+ "github_com_charmbracelet_crush_internal_config.Options": {
2927+ "type": "object",
2928+ "properties": {
2929+ "attribution": {
2930+ "$ref": "#/definitions/config.Attribution"
2931+ },
2932+ "auto_lsp": {
2933+ "type": "boolean"
2934+ },
2935+ "context_paths": {
2936+ "type": "array",
2937+ "items": {
2938+ "type": "string"
2939+ }
2940+ },
2941+ "data_directory": {
2942+ "description": "Relative to the cwd",
2943+ "type": "string"
2944+ },
2945+ "debug": {
2946+ "type": "boolean"
2947+ },
2948+ "debug_lsp": {
2949+ "type": "boolean"
2950+ },
2951+ "disable_auto_summarize": {
2952+ "type": "boolean"
2953+ },
2954+ "disable_default_providers": {
2955+ "type": "boolean"
2956+ },
2957+ "disable_metrics": {
2958+ "type": "boolean"
2959+ },
2960+ "disable_notifications": {
2961+ "type": "boolean"
2962+ },
2963+ "disable_provider_auto_update": {
2964+ "type": "boolean"
2965+ },
2966+ "disabled_tools": {
2967+ "type": "array",
2968+ "items": {
2969+ "type": "string"
2970+ }
2971+ },
2972+ "initialize_as": {
2973+ "type": "string"
2974+ },
2975+ "progress": {
2976+ "type": "boolean"
2977+ },
2978+ "skills_paths": {
2979+ "type": "array",
2980+ "items": {
2981+ "type": "string"
2982+ }
2983+ },
2984+ "tui": {
2985+ "$ref": "#/definitions/config.TUIOptions"
2986+ }
2987+ }
2988+ },
2989+ "github_com_charmbracelet_crush_internal_proto.Message": {
2990+ "type": "object",
2991+ "properties": {
2992+ "created_at": {
2993+ "type": "integer"
2994+ },
2995+ "id": {
2996+ "type": "string"
2997+ },
2998+ "model": {
2999+ "type": "string"
3000+ },
3001+ "parts": {
3002+ "type": "array",
3003+ "items": {}
3004+ },
3005+ "provider": {
3006+ "type": "string"
3007+ },
3008+ "role": {
3009+ "$ref": "#/definitions/proto.MessageRole"
3010+ },
3011+ "session_id": {
3012+ "type": "string"
3013+ },
3014+ "updated_at": {
3015+ "type": "integer"
3016+ }
3017+ }
3018+ },
3019+ "lsp.ServerState": {
3020+ "type": "integer",
3021+ "enum": [
3022+ 0,
3023+ 1,
3024+ 2,
3025+ 3,
3026+ 4,
3027+ 5
3028+ ],
3029+ "x-enum-varnames": [
3030+ "StateUnstarted",
3031+ "StateStarting",
3032+ "StateReady",
3033+ "StateError",
3034+ "StateStopped",
3035+ "StateDisabled"
3036+ ]
3037+ },
3038+ "proto.AgentInfo": {
3039+ "type": "object",
3040+ "properties": {
3041+ "is_busy": {
3042+ "type": "boolean"
3043+ },
3044+ "is_ready": {
3045+ "type": "boolean"
3046+ },
3047+ "model": {
3048+ "$ref": "#/definitions/catwalk.Model"
3049+ },
3050+ "model_cfg": {
3051+ "$ref": "#/definitions/config.SelectedModel"
3052+ }
3053+ }
3054+ },
3055+ "proto.AgentMessage": {
3056+ "type": "object",
3057+ "properties": {
3058+ "attachments": {
3059+ "type": "array",
3060+ "items": {
3061+ "$ref": "#/definitions/proto.Attachment"
3062+ }
3063+ },
3064+ "prompt": {
3065+ "type": "string"
3066+ },
3067+ "session_id": {
3068+ "type": "string"
3069+ }
3070+ }
3071+ },
3072+ "proto.AgentSession": {
3073+ "type": "object",
3074+ "properties": {
3075+ "completion_tokens": {
3076+ "type": "integer"
3077+ },
3078+ "cost": {
3079+ "type": "number"
3080+ },
3081+ "created_at": {
3082+ "type": "integer"
3083+ },
3084+ "id": {
3085+ "type": "string"
3086+ },
3087+ "is_busy": {
3088+ "type": "boolean"
3089+ },
3090+ "message_count": {
3091+ "type": "integer"
3092+ },
3093+ "parent_session_id": {
3094+ "type": "string"
3095+ },
3096+ "prompt_tokens": {
3097+ "type": "integer"
3098+ },
3099+ "summary_message_id": {
3100+ "type": "string"
3101+ },
3102+ "title": {
3103+ "type": "string"
3104+ },
3105+ "updated_at": {
3106+ "type": "integer"
3107+ }
3108+ }
3109+ },
3110+ "proto.Attachment": {
3111+ "type": "object",
3112+ "properties": {
3113+ "content": {
3114+ "type": "array",
3115+ "items": {
3116+ "type": "integer"
3117+ }
3118+ },
3119+ "file_name": {
3120+ "type": "string"
3121+ },
3122+ "file_path": {
3123+ "type": "string"
3124+ },
3125+ "mime_type": {
3126+ "type": "string"
3127+ }
3128+ }
3129+ },
3130+ "proto.ConfigCompactRequest": {
3131+ "type": "object",
3132+ "properties": {
3133+ "enabled": {
3134+ "type": "boolean"
3135+ },
3136+ "scope": {
3137+ "$ref": "#/definitions/config.Scope"
3138+ }
3139+ }
3140+ },
3141+ "proto.ConfigModelRequest": {
3142+ "type": "object",
3143+ "properties": {
3144+ "model": {
3145+ "$ref": "#/definitions/config.SelectedModel"
3146+ },
3147+ "model_type": {
3148+ "$ref": "#/definitions/config.SelectedModelType"
3149+ },
3150+ "scope": {
3151+ "$ref": "#/definitions/config.Scope"
3152+ }
3153+ }
3154+ },
3155+ "proto.ConfigProviderKeyRequest": {
3156+ "type": "object",
3157+ "properties": {
3158+ "api_key": {},
3159+ "provider_id": {
3160+ "type": "string"
3161+ },
3162+ "scope": {
3163+ "$ref": "#/definitions/config.Scope"
3164+ }
3165+ }
3166+ },
3167+ "proto.ConfigRefreshOAuthRequest": {
3168+ "type": "object",
3169+ "properties": {
3170+ "provider_id": {
3171+ "type": "string"
3172+ },
3173+ "scope": {
3174+ "$ref": "#/definitions/config.Scope"
3175+ }
3176+ }
3177+ },
3178+ "proto.ConfigRemoveRequest": {
3179+ "type": "object",
3180+ "properties": {
3181+ "key": {
3182+ "type": "string"
3183+ },
3184+ "scope": {
3185+ "$ref": "#/definitions/config.Scope"
3186+ }
3187+ }
3188+ },
3189+ "proto.ConfigSetRequest": {
3190+ "type": "object",
3191+ "properties": {
3192+ "key": {
3193+ "type": "string"
3194+ },
3195+ "scope": {
3196+ "$ref": "#/definitions/config.Scope"
3197+ },
3198+ "value": {}
3199+ }
3200+ },
3201+ "proto.Error": {
3202+ "type": "object",
3203+ "properties": {
3204+ "message": {
3205+ "type": "string"
3206+ }
3207+ }
3208+ },
3209+ "proto.File": {
3210+ "type": "object",
3211+ "properties": {
3212+ "content": {
3213+ "type": "string"
3214+ },
3215+ "created_at": {
3216+ "type": "integer"
3217+ },
3218+ "id": {
3219+ "type": "string"
3220+ },
3221+ "path": {
3222+ "type": "string"
3223+ },
3224+ "session_id": {
3225+ "type": "string"
3226+ },
3227+ "updated_at": {
3228+ "type": "integer"
3229+ },
3230+ "version": {
3231+ "type": "integer"
3232+ }
3233+ }
3234+ },
3235+ "proto.FileTrackerReadRequest": {
3236+ "type": "object",
3237+ "properties": {
3238+ "path": {
3239+ "type": "string"
3240+ },
3241+ "session_id": {
3242+ "type": "string"
3243+ }
3244+ }
3245+ },
3246+ "proto.ImportCopilotResponse": {
3247+ "type": "object",
3248+ "properties": {
3249+ "success": {
3250+ "type": "boolean"
3251+ },
3252+ "token": {}
3253+ }
3254+ },
3255+ "proto.LSPClientInfo": {
3256+ "type": "object",
3257+ "properties": {
3258+ "connected_at": {
3259+ "type": "string"
3260+ },
3261+ "diagnostic_count": {
3262+ "type": "integer"
3263+ },
3264+ "error": {},
3265+ "name": {
3266+ "type": "string"
3267+ },
3268+ "state": {
3269+ "$ref": "#/definitions/lsp.ServerState"
3270+ }
3271+ }
3272+ },
3273+ "proto.LSPStartRequest": {
3274+ "type": "object",
3275+ "properties": {
3276+ "path": {
3277+ "type": "string"
3278+ }
3279+ }
3280+ },
3281+ "proto.MCPClientInfo": {
3282+ "type": "object",
3283+ "properties": {
3284+ "connected_at": {
3285+ "type": "string"
3286+ },
3287+ "error": {},
3288+ "name": {
3289+ "type": "string"
3290+ },
3291+ "prompt_count": {
3292+ "type": "integer"
3293+ },
3294+ "resource_count": {
3295+ "type": "integer"
3296+ },
3297+ "state": {
3298+ "$ref": "#/definitions/proto.MCPState"
3299+ },
3300+ "tool_count": {
3301+ "type": "integer"
3302+ }
3303+ }
3304+ },
3305+ "proto.MCPGetPromptRequest": {
3306+ "type": "object",
3307+ "properties": {
3308+ "args": {
3309+ "type": "object",
3310+ "additionalProperties": {
3311+ "type": "string"
3312+ }
3313+ },
3314+ "client_id": {
3315+ "type": "string"
3316+ },
3317+ "prompt_id": {
3318+ "type": "string"
3319+ }
3320+ }
3321+ },
3322+ "proto.MCPGetPromptResponse": {
3323+ "type": "object",
3324+ "properties": {
3325+ "prompt": {
3326+ "type": "string"
3327+ }
3328+ }
3329+ },
3330+ "proto.MCPNameRequest": {
3331+ "type": "object",
3332+ "properties": {
3333+ "name": {
3334+ "type": "string"
3335+ }
3336+ }
3337+ },
3338+ "proto.MCPReadResourceRequest": {
3339+ "type": "object",
3340+ "properties": {
3341+ "name": {
3342+ "type": "string"
3343+ },
3344+ "uri": {
3345+ "type": "string"
3346+ }
3347+ }
3348+ },
3349+ "proto.MCPState": {
3350+ "type": "integer",
3351+ "enum": [
3352+ 0,
3353+ 1,
3354+ 2,
3355+ 3
3356+ ],
3357+ "x-enum-varnames": [
3358+ "MCPStateDisabled",
3359+ "MCPStateStarting",
3360+ "MCPStateConnected",
3361+ "MCPStateError"
3362+ ]
3363+ },
3364+ "proto.MessageRole": {
3365+ "type": "string",
3366+ "enum": [
3367+ "assistant",
3368+ "user",
3369+ "system",
3370+ "tool"
3371+ ],
3372+ "x-enum-varnames": [
3373+ "Assistant",
3374+ "User",
3375+ "System",
3376+ "Tool"
3377+ ]
3378+ },
3379+ "proto.PermissionAction": {
3380+ "type": "string",
3381+ "enum": [
3382+ "allow",
3383+ "allow_session",
3384+ "deny"
3385+ ],
3386+ "x-enum-varnames": [
3387+ "PermissionAllow",
3388+ "PermissionAllowForSession",
3389+ "PermissionDeny"
3390+ ]
3391+ },
3392+ "proto.PermissionGrant": {
3393+ "type": "object",
3394+ "properties": {
3395+ "action": {
3396+ "$ref": "#/definitions/proto.PermissionAction"
3397+ },
3398+ "permission": {
3399+ "$ref": "#/definitions/proto.PermissionRequest"
3400+ }
3401+ }
3402+ },
3403+ "proto.PermissionRequest": {
3404+ "type": "object",
3405+ "properties": {
3406+ "action": {
3407+ "type": "string"
3408+ },
3409+ "description": {
3410+ "type": "string"
3411+ },
3412+ "id": {
3413+ "type": "string"
3414+ },
3415+ "params": {},
3416+ "path": {
3417+ "type": "string"
3418+ },
3419+ "session_id": {
3420+ "type": "string"
3421+ },
3422+ "tool_call_id": {
3423+ "type": "string"
3424+ },
3425+ "tool_name": {
3426+ "type": "string"
3427+ }
3428+ }
3429+ },
3430+ "proto.PermissionSkipRequest": {
3431+ "type": "object",
3432+ "properties": {
3433+ "skip": {
3434+ "type": "boolean"
3435+ }
3436+ }
3437+ },
3438+ "proto.ProjectInitPromptResponse": {
3439+ "type": "object",
3440+ "properties": {
3441+ "prompt": {
3442+ "type": "string"
3443+ }
3444+ }
3445+ },
3446+ "proto.ProjectNeedsInitResponse": {
3447+ "type": "object",
3448+ "properties": {
3449+ "needs_init": {
3450+ "type": "boolean"
3451+ }
3452+ }
3453+ },
3454+ "proto.ServerControl": {
3455+ "type": "object",
3456+ "properties": {
3457+ "command": {
3458+ "type": "string"
3459+ }
3460+ }
3461+ },
3462+ "proto.Session": {
3463+ "type": "object",
3464+ "properties": {
3465+ "completion_tokens": {
3466+ "type": "integer"
3467+ },
3468+ "cost": {
3469+ "type": "number"
3470+ },
3471+ "created_at": {
3472+ "type": "integer"
3473+ },
3474+ "id": {
3475+ "type": "string"
3476+ },
3477+ "message_count": {
3478+ "type": "integer"
3479+ },
3480+ "parent_session_id": {
3481+ "type": "string"
3482+ },
3483+ "prompt_tokens": {
3484+ "type": "integer"
3485+ },
3486+ "summary_message_id": {
3487+ "type": "string"
3488+ },
3489+ "title": {
3490+ "type": "string"
3491+ },
3492+ "updated_at": {
3493+ "type": "integer"
3494+ }
3495+ }
3496+ },
3497+ "proto.VersionInfo": {
3498+ "type": "object",
3499+ "properties": {
3500+ "commit": {
3501+ "type": "string"
3502+ },
3503+ "go_version": {
3504+ "type": "string"
3505+ },
3506+ "platform": {
3507+ "type": "string"
3508+ },
3509+ "version": {
3510+ "type": "string"
3511+ }
3512+ }
3513+ },
3514+ "proto.Workspace": {
3515+ "type": "object",
3516+ "properties": {
3517+ "config": {
3518+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_config.Config"
3519+ },
3520+ "data_dir": {
3521+ "type": "string"
3522+ },
3523+ "debug": {
3524+ "type": "boolean"
3525+ },
3526+ "env": {
3527+ "type": "array",
3528+ "items": {
3529+ "type": "string"
3530+ }
3531+ },
3532+ "id": {
3533+ "type": "string"
3534+ },
3535+ "path": {
3536+ "type": "string"
3537+ },
3538+ "version": {
3539+ "type": "string"
3540+ },
3541+ "yolo": {
3542+ "type": "boolean"
3543+ }
3544+ }
3545+ },
3546+ "time.Duration": {
3547+ "type": "integer",
3548+ "format": "int64",
3549+ "enum": [
3550+ -9223372036854775808,
3551+ 9223372036854775807,
3552+ 1,
3553+ 1000,
3554+ 1000000,
3555+ 1000000000,
3556+ 60000000000,
3557+ 3600000000000
3558+ ],
3559+ "x-enum-varnames": [
3560+ "minDuration",
3561+ "maxDuration",
3562+ "Nanosecond",
3563+ "Microsecond",
3564+ "Millisecond",
3565+ "Second",
3566+ "Minute",
3567+ "Hour"
3568+ ]
3569+ }
3570+ }
3571+}`
3572+
3573+// SwaggerInfo holds exported Swagger Info so clients can modify it
3574+var SwaggerInfo = &swag.Spec{
3575+ Version: "1.0",
3576+ Host: "",
3577+ BasePath: "/v1",
3578+ Schemes: []string{},
3579+ Title: "Crush API",
3580+ Description: "Crush is a terminal-based AI coding assistant. This API is served over a Unix socket (or Windows named pipe) and provides programmatic access to workspaces, sessions, agents, LSP, MCP, and more.",
3581+ InfoInstanceName: "swagger",
3582+ SwaggerTemplate: docTemplate,
3583+ LeftDelim: "{{",
3584+ RightDelim: "}}",
3585+}
3586+
3587+func init() {
3588+ swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
3589+}
internal/swagger/swagger.jsonnew file+3564
@@ -0,0 +1,3564 @@
1+{
2+ "swagger": "2.0",
3+ "info": {
4+ "description": "Crush is a terminal-based AI coding assistant. This API is served over a Unix socket (or Windows named pipe) and provides programmatic access to workspaces, sessions, agents, LSP, MCP, and more.",
5+ "title": "Crush API",
6+ "contact": {
7+ "name": "Charm",
8+ "url": "https://charm.sh"
9+ },
10+ "license": {
11+ "name": "MIT",
12+ "url": "https://github.com/charmbracelet/crush/blob/main/LICENSE"
13+ },
14+ "version": "1.0"
15+ },
16+ "basePath": "/v1",
17+ "paths": {
18+ "/config": {
19+ "get": {
20+ "produces": [
21+ "application/json"
22+ ],
23+ "tags": [
24+ "system"
25+ ],
26+ "summary": "Get server config",
27+ "responses": {
28+ "200": {
29+ "description": "OK",
30+ "schema": {
31+ "type": "object"
32+ }
33+ }
34+ }
35+ }
36+ },
37+ "/control": {
38+ "post": {
39+ "consumes": [
40+ "application/json"
41+ ],
42+ "tags": [
43+ "system"
44+ ],
45+ "summary": "Send server control command",
46+ "parameters": [
47+ {
48+ "description": "Control command (e.g. shutdown)",
49+ "name": "request",
50+ "in": "body",
51+ "required": true,
52+ "schema": {
53+ "$ref": "#/definitions/proto.ServerControl"
54+ }
55+ }
56+ ],
57+ "responses": {
58+ "200": {
59+ "description": "OK"
60+ },
61+ "400": {
62+ "description": "Bad Request",
63+ "schema": {
64+ "$ref": "#/definitions/proto.Error"
65+ }
66+ }
67+ }
68+ }
69+ },
70+ "/health": {
71+ "get": {
72+ "tags": [
73+ "system"
74+ ],
75+ "summary": "Health check",
76+ "responses": {
77+ "200": {
78+ "description": "OK"
79+ }
80+ }
81+ }
82+ },
83+ "/version": {
84+ "get": {
85+ "produces": [
86+ "application/json"
87+ ],
88+ "tags": [
89+ "system"
90+ ],
91+ "summary": "Get server version",
92+ "responses": {
93+ "200": {
94+ "description": "OK",
95+ "schema": {
96+ "$ref": "#/definitions/proto.VersionInfo"
97+ }
98+ }
99+ }
100+ }
101+ },
102+ "/workspaces": {
103+ "get": {
104+ "produces": [
105+ "application/json"
106+ ],
107+ "tags": [
108+ "workspaces"
109+ ],
110+ "summary": "List workspaces",
111+ "responses": {
112+ "200": {
113+ "description": "OK",
114+ "schema": {
115+ "type": "array",
116+ "items": {
117+ "$ref": "#/definitions/proto.Workspace"
118+ }
119+ }
120+ }
121+ }
122+ },
123+ "post": {
124+ "consumes": [
125+ "application/json"
126+ ],
127+ "produces": [
128+ "application/json"
129+ ],
130+ "tags": [
131+ "workspaces"
132+ ],
133+ "summary": "Create workspace",
134+ "parameters": [
135+ {
136+ "description": "Workspace creation params",
137+ "name": "request",
138+ "in": "body",
139+ "required": true,
140+ "schema": {
141+ "$ref": "#/definitions/proto.Workspace"
142+ }
143+ }
144+ ],
145+ "responses": {
146+ "200": {
147+ "description": "OK",
148+ "schema": {
149+ "$ref": "#/definitions/proto.Workspace"
150+ }
151+ },
152+ "400": {
153+ "description": "Bad Request",
154+ "schema": {
155+ "$ref": "#/definitions/proto.Error"
156+ }
157+ },
158+ "500": {
159+ "description": "Internal Server Error",
160+ "schema": {
161+ "$ref": "#/definitions/proto.Error"
162+ }
163+ }
164+ }
165+ }
166+ },
167+ "/workspaces/{id}": {
168+ "get": {
169+ "produces": [
170+ "application/json"
171+ ],
172+ "tags": [
173+ "workspaces"
174+ ],
175+ "summary": "Get workspace",
176+ "parameters": [
177+ {
178+ "type": "string",
179+ "description": "Workspace ID",
180+ "name": "id",
181+ "in": "path",
182+ "required": true
183+ }
184+ ],
185+ "responses": {
186+ "200": {
187+ "description": "OK",
188+ "schema": {
189+ "$ref": "#/definitions/proto.Workspace"
190+ }
191+ },
192+ "404": {
193+ "description": "Not Found",
194+ "schema": {
195+ "$ref": "#/definitions/proto.Error"
196+ }
197+ },
198+ "500": {
199+ "description": "Internal Server Error",
200+ "schema": {
201+ "$ref": "#/definitions/proto.Error"
202+ }
203+ }
204+ }
205+ },
206+ "delete": {
207+ "tags": [
208+ "workspaces"
209+ ],
210+ "summary": "Delete workspace",
211+ "parameters": [
212+ {
213+ "type": "string",
214+ "description": "Workspace ID",
215+ "name": "id",
216+ "in": "path",
217+ "required": true
218+ }
219+ ],
220+ "responses": {
221+ "200": {
222+ "description": "OK"
223+ },
224+ "404": {
225+ "description": "Not Found",
226+ "schema": {
227+ "$ref": "#/definitions/proto.Error"
228+ }
229+ }
230+ }
231+ }
232+ },
233+ "/workspaces/{id}/agent": {
234+ "get": {
235+ "produces": [
236+ "application/json"
237+ ],
238+ "tags": [
239+ "agent"
240+ ],
241+ "summary": "Get agent info",
242+ "parameters": [
243+ {
244+ "type": "string",
245+ "description": "Workspace ID",
246+ "name": "id",
247+ "in": "path",
248+ "required": true
249+ }
250+ ],
251+ "responses": {
252+ "200": {
253+ "description": "OK",
254+ "schema": {
255+ "$ref": "#/definitions/proto.AgentInfo"
256+ }
257+ },
258+ "404": {
259+ "description": "Not Found",
260+ "schema": {
261+ "$ref": "#/definitions/proto.Error"
262+ }
263+ },
264+ "500": {
265+ "description": "Internal Server Error",
266+ "schema": {
267+ "$ref": "#/definitions/proto.Error"
268+ }
269+ }
270+ }
271+ },
272+ "post": {
273+ "consumes": [
274+ "application/json"
275+ ],
276+ "tags": [
277+ "agent"
278+ ],
279+ "summary": "Send message to agent",
280+ "parameters": [
281+ {
282+ "type": "string",
283+ "description": "Workspace ID",
284+ "name": "id",
285+ "in": "path",
286+ "required": true
287+ },
288+ {
289+ "description": "Agent message",
290+ "name": "request",
291+ "in": "body",
292+ "required": true,
293+ "schema": {
294+ "$ref": "#/definitions/proto.AgentMessage"
295+ }
296+ }
297+ ],
298+ "responses": {
299+ "200": {
300+ "description": "OK"
301+ },
302+ "400": {
303+ "description": "Bad Request",
304+ "schema": {
305+ "$ref": "#/definitions/proto.Error"
306+ }
307+ },
308+ "404": {
309+ "description": "Not Found",
310+ "schema": {
311+ "$ref": "#/definitions/proto.Error"
312+ }
313+ },
314+ "500": {
315+ "description": "Internal Server Error",
316+ "schema": {
317+ "$ref": "#/definitions/proto.Error"
318+ }
319+ }
320+ }
321+ }
322+ },
323+ "/workspaces/{id}/agent/default-small-model": {
324+ "get": {
325+ "produces": [
326+ "application/json"
327+ ],
328+ "tags": [
329+ "agent"
330+ ],
331+ "summary": "Get default small model",
332+ "parameters": [
333+ {
334+ "type": "string",
335+ "description": "Workspace ID",
336+ "name": "id",
337+ "in": "path",
338+ "required": true
339+ },
340+ {
341+ "type": "string",
342+ "description": "Provider ID",
343+ "name": "provider_id",
344+ "in": "query"
345+ }
346+ ],
347+ "responses": {
348+ "200": {
349+ "description": "OK",
350+ "schema": {
351+ "type": "object"
352+ }
353+ },
354+ "404": {
355+ "description": "Not Found",
356+ "schema": {
357+ "$ref": "#/definitions/proto.Error"
358+ }
359+ },
360+ "500": {
361+ "description": "Internal Server Error",
362+ "schema": {
363+ "$ref": "#/definitions/proto.Error"
364+ }
365+ }
366+ }
367+ }
368+ },
369+ "/workspaces/{id}/agent/init": {
370+ "post": {
371+ "tags": [
372+ "agent"
373+ ],
374+ "summary": "Initialize agent",
375+ "parameters": [
376+ {
377+ "type": "string",
378+ "description": "Workspace ID",
379+ "name": "id",
380+ "in": "path",
381+ "required": true
382+ }
383+ ],
384+ "responses": {
385+ "200": {
386+ "description": "OK"
387+ },
388+ "404": {
389+ "description": "Not Found",
390+ "schema": {
391+ "$ref": "#/definitions/proto.Error"
392+ }
393+ },
394+ "500": {
395+ "description": "Internal Server Error",
396+ "schema": {
397+ "$ref": "#/definitions/proto.Error"
398+ }
399+ }
400+ }
401+ }
402+ },
403+ "/workspaces/{id}/agent/sessions/{sid}": {
404+ "get": {
405+ "produces": [
406+ "application/json"
407+ ],
408+ "tags": [
409+ "agent"
410+ ],
411+ "summary": "Get agent session",
412+ "parameters": [
413+ {
414+ "type": "string",
415+ "description": "Workspace ID",
416+ "name": "id",
417+ "in": "path",
418+ "required": true
419+ },
420+ {
421+ "type": "string",
422+ "description": "Session ID",
423+ "name": "sid",
424+ "in": "path",
425+ "required": true
426+ }
427+ ],
428+ "responses": {
429+ "200": {
430+ "description": "OK",
431+ "schema": {
432+ "$ref": "#/definitions/proto.AgentSession"
433+ }
434+ },
435+ "404": {
436+ "description": "Not Found",
437+ "schema": {
438+ "$ref": "#/definitions/proto.Error"
439+ }
440+ },
441+ "500": {
442+ "description": "Internal Server Error",
443+ "schema": {
444+ "$ref": "#/definitions/proto.Error"
445+ }
446+ }
447+ }
448+ }
449+ },
450+ "/workspaces/{id}/agent/sessions/{sid}/cancel": {
451+ "post": {
452+ "tags": [
453+ "agent"
454+ ],
455+ "summary": "Cancel agent session",
456+ "parameters": [
457+ {
458+ "type": "string",
459+ "description": "Workspace ID",
460+ "name": "id",
461+ "in": "path",
462+ "required": true
463+ },
464+ {
465+ "type": "string",
466+ "description": "Session ID",
467+ "name": "sid",
468+ "in": "path",
469+ "required": true
470+ }
471+ ],
472+ "responses": {
473+ "200": {
474+ "description": "OK"
475+ },
476+ "404": {
477+ "description": "Not Found",
478+ "schema": {
479+ "$ref": "#/definitions/proto.Error"
480+ }
481+ },
482+ "500": {
483+ "description": "Internal Server Error",
484+ "schema": {
485+ "$ref": "#/definitions/proto.Error"
486+ }
487+ }
488+ }
489+ }
490+ },
491+ "/workspaces/{id}/agent/sessions/{sid}/prompts/clear": {
492+ "post": {
493+ "tags": [
494+ "agent"
495+ ],
496+ "summary": "Clear prompt queue",
497+ "parameters": [
498+ {
499+ "type": "string",
500+ "description": "Workspace ID",
501+ "name": "id",
502+ "in": "path",
503+ "required": true
504+ },
505+ {
506+ "type": "string",
507+ "description": "Session ID",
508+ "name": "sid",
509+ "in": "path",
510+ "required": true
511+ }
512+ ],
513+ "responses": {
514+ "200": {
515+ "description": "OK"
516+ },
517+ "404": {
518+ "description": "Not Found",
519+ "schema": {
520+ "$ref": "#/definitions/proto.Error"
521+ }
522+ },
523+ "500": {
524+ "description": "Internal Server Error",
525+ "schema": {
526+ "$ref": "#/definitions/proto.Error"
527+ }
528+ }
529+ }
530+ }
531+ },
532+ "/workspaces/{id}/agent/sessions/{sid}/prompts/list": {
533+ "get": {
534+ "produces": [
535+ "application/json"
536+ ],
537+ "tags": [
538+ "agent"
539+ ],
540+ "summary": "List queued prompts",
541+ "parameters": [
542+ {
543+ "type": "string",
544+ "description": "Workspace ID",
545+ "name": "id",
546+ "in": "path",
547+ "required": true
548+ },
549+ {
550+ "type": "string",
551+ "description": "Session ID",
552+ "name": "sid",
553+ "in": "path",
554+ "required": true
555+ }
556+ ],
557+ "responses": {
558+ "200": {
559+ "description": "OK",
560+ "schema": {
561+ "type": "array",
562+ "items": {
563+ "type": "string"
564+ }
565+ }
566+ },
567+ "404": {
568+ "description": "Not Found",
569+ "schema": {
570+ "$ref": "#/definitions/proto.Error"
571+ }
572+ },
573+ "500": {
574+ "description": "Internal Server Error",
575+ "schema": {
576+ "$ref": "#/definitions/proto.Error"
577+ }
578+ }
579+ }
580+ }
581+ },
582+ "/workspaces/{id}/agent/sessions/{sid}/prompts/queued": {
583+ "get": {
584+ "produces": [
585+ "application/json"
586+ ],
587+ "tags": [
588+ "agent"
589+ ],
590+ "summary": "Get queued prompt status",
591+ "parameters": [
592+ {
593+ "type": "string",
594+ "description": "Workspace ID",
595+ "name": "id",
596+ "in": "path",
597+ "required": true
598+ },
599+ {
600+ "type": "string",
601+ "description": "Session ID",
602+ "name": "sid",
603+ "in": "path",
604+ "required": true
605+ }
606+ ],
607+ "responses": {
608+ "200": {
609+ "description": "OK",
610+ "schema": {
611+ "type": "object"
612+ }
613+ },
614+ "404": {
615+ "description": "Not Found",
616+ "schema": {
617+ "$ref": "#/definitions/proto.Error"
618+ }
619+ },
620+ "500": {
621+ "description": "Internal Server Error",
622+ "schema": {
623+ "$ref": "#/definitions/proto.Error"
624+ }
625+ }
626+ }
627+ }
628+ },
629+ "/workspaces/{id}/agent/sessions/{sid}/summarize": {
630+ "post": {
631+ "tags": [
632+ "agent"
633+ ],
634+ "summary": "Summarize session",
635+ "parameters": [
636+ {
637+ "type": "string",
638+ "description": "Workspace ID",
639+ "name": "id",
640+ "in": "path",
641+ "required": true
642+ },
643+ {
644+ "type": "string",
645+ "description": "Session ID",
646+ "name": "sid",
647+ "in": "path",
648+ "required": true
649+ }
650+ ],
651+ "responses": {
652+ "200": {
653+ "description": "OK"
654+ },
655+ "404": {
656+ "description": "Not Found",
657+ "schema": {
658+ "$ref": "#/definitions/proto.Error"
659+ }
660+ },
661+ "500": {
662+ "description": "Internal Server Error",
663+ "schema": {
664+ "$ref": "#/definitions/proto.Error"
665+ }
666+ }
667+ }
668+ }
669+ },
670+ "/workspaces/{id}/agent/update": {
671+ "post": {
672+ "tags": [
673+ "agent"
674+ ],
675+ "summary": "Update agent",
676+ "parameters": [
677+ {
678+ "type": "string",
679+ "description": "Workspace ID",
680+ "name": "id",
681+ "in": "path",
682+ "required": true
683+ }
684+ ],
685+ "responses": {
686+ "200": {
687+ "description": "OK"
688+ },
689+ "404": {
690+ "description": "Not Found",
691+ "schema": {
692+ "$ref": "#/definitions/proto.Error"
693+ }
694+ },
695+ "500": {
696+ "description": "Internal Server Error",
697+ "schema": {
698+ "$ref": "#/definitions/proto.Error"
699+ }
700+ }
701+ }
702+ }
703+ },
704+ "/workspaces/{id}/config": {
705+ "get": {
706+ "produces": [
707+ "application/json"
708+ ],
709+ "tags": [
710+ "workspaces"
711+ ],
712+ "summary": "Get workspace config",
713+ "parameters": [
714+ {
715+ "type": "string",
716+ "description": "Workspace ID",
717+ "name": "id",
718+ "in": "path",
719+ "required": true
720+ }
721+ ],
722+ "responses": {
723+ "200": {
724+ "description": "OK",
725+ "schema": {
726+ "type": "object"
727+ }
728+ },
729+ "404": {
730+ "description": "Not Found",
731+ "schema": {
732+ "$ref": "#/definitions/proto.Error"
733+ }
734+ },
735+ "500": {
736+ "description": "Internal Server Error",
737+ "schema": {
738+ "$ref": "#/definitions/proto.Error"
739+ }
740+ }
741+ }
742+ }
743+ },
744+ "/workspaces/{id}/config/compact": {
745+ "post": {
746+ "consumes": [
747+ "application/json"
748+ ],
749+ "tags": [
750+ "config"
751+ ],
752+ "summary": "Set compact mode",
753+ "parameters": [
754+ {
755+ "type": "string",
756+ "description": "Workspace ID",
757+ "name": "id",
758+ "in": "path",
759+ "required": true
760+ },
761+ {
762+ "description": "Config compact request",
763+ "name": "request",
764+ "in": "body",
765+ "required": true,
766+ "schema": {
767+ "$ref": "#/definitions/proto.ConfigCompactRequest"
768+ }
769+ }
770+ ],
771+ "responses": {
772+ "200": {
773+ "description": "OK"
774+ },
775+ "400": {
776+ "description": "Bad Request",
777+ "schema": {
778+ "$ref": "#/definitions/proto.Error"
779+ }
780+ },
781+ "404": {
782+ "description": "Not Found",
783+ "schema": {
784+ "$ref": "#/definitions/proto.Error"
785+ }
786+ },
787+ "500": {
788+ "description": "Internal Server Error",
789+ "schema": {
790+ "$ref": "#/definitions/proto.Error"
791+ }
792+ }
793+ }
794+ }
795+ },
796+ "/workspaces/{id}/config/import-copilot": {
797+ "post": {
798+ "produces": [
799+ "application/json"
800+ ],
801+ "tags": [
802+ "config"
803+ ],
804+ "summary": "Import Copilot credentials",
805+ "parameters": [
806+ {
807+ "type": "string",
808+ "description": "Workspace ID",
809+ "name": "id",
810+ "in": "path",
811+ "required": true
812+ }
813+ ],
814+ "responses": {
815+ "200": {
816+ "description": "OK",
817+ "schema": {
818+ "$ref": "#/definitions/proto.ImportCopilotResponse"
819+ }
820+ },
821+ "404": {
822+ "description": "Not Found",
823+ "schema": {
824+ "$ref": "#/definitions/proto.Error"
825+ }
826+ },
827+ "500": {
828+ "description": "Internal Server Error",
829+ "schema": {
830+ "$ref": "#/definitions/proto.Error"
831+ }
832+ }
833+ }
834+ }
835+ },
836+ "/workspaces/{id}/config/model": {
837+ "post": {
838+ "consumes": [
839+ "application/json"
840+ ],
841+ "tags": [
842+ "config"
843+ ],
844+ "summary": "Set the preferred model",
845+ "parameters": [
846+ {
847+ "type": "string",
848+ "description": "Workspace ID",
849+ "name": "id",
850+ "in": "path",
851+ "required": true
852+ },
853+ {
854+ "description": "Config model request",
855+ "name": "request",
856+ "in": "body",
857+ "required": true,
858+ "schema": {
859+ "$ref": "#/definitions/proto.ConfigModelRequest"
860+ }
861+ }
862+ ],
863+ "responses": {
864+ "200": {
865+ "description": "OK"
866+ },
867+ "400": {
868+ "description": "Bad Request",
869+ "schema": {
870+ "$ref": "#/definitions/proto.Error"
871+ }
872+ },
873+ "404": {
874+ "description": "Not Found",
875+ "schema": {
876+ "$ref": "#/definitions/proto.Error"
877+ }
878+ },
879+ "500": {
880+ "description": "Internal Server Error",
881+ "schema": {
882+ "$ref": "#/definitions/proto.Error"
883+ }
884+ }
885+ }
886+ }
887+ },
888+ "/workspaces/{id}/config/provider-key": {
889+ "post": {
890+ "consumes": [
891+ "application/json"
892+ ],
893+ "tags": [
894+ "config"
895+ ],
896+ "summary": "Set provider API key",
897+ "parameters": [
898+ {
899+ "type": "string",
900+ "description": "Workspace ID",
901+ "name": "id",
902+ "in": "path",
903+ "required": true
904+ },
905+ {
906+ "description": "Config provider key request",
907+ "name": "request",
908+ "in": "body",
909+ "required": true,
910+ "schema": {
911+ "$ref": "#/definitions/proto.ConfigProviderKeyRequest"
912+ }
913+ }
914+ ],
915+ "responses": {
916+ "200": {
917+ "description": "OK"
918+ },
919+ "400": {
920+ "description": "Bad Request",
921+ "schema": {
922+ "$ref": "#/definitions/proto.Error"
923+ }
924+ },
925+ "404": {
926+ "description": "Not Found",
927+ "schema": {
928+ "$ref": "#/definitions/proto.Error"
929+ }
930+ },
931+ "500": {
932+ "description": "Internal Server Error",
933+ "schema": {
934+ "$ref": "#/definitions/proto.Error"
935+ }
936+ }
937+ }
938+ }
939+ },
940+ "/workspaces/{id}/config/refresh-oauth": {
941+ "post": {
942+ "consumes": [
943+ "application/json"
944+ ],
945+ "tags": [
946+ "config"
947+ ],
948+ "summary": "Refresh OAuth token",
949+ "parameters": [
950+ {
951+ "type": "string",
952+ "description": "Workspace ID",
953+ "name": "id",
954+ "in": "path",
955+ "required": true
956+ },
957+ {
958+ "description": "Refresh OAuth request",
959+ "name": "request",
960+ "in": "body",
961+ "required": true,
962+ "schema": {
963+ "$ref": "#/definitions/proto.ConfigRefreshOAuthRequest"
964+ }
965+ }
966+ ],
967+ "responses": {
968+ "200": {
969+ "description": "OK"
970+ },
971+ "400": {
972+ "description": "Bad Request",
973+ "schema": {
974+ "$ref": "#/definitions/proto.Error"
975+ }
976+ },
977+ "404": {
978+ "description": "Not Found",
979+ "schema": {
980+ "$ref": "#/definitions/proto.Error"
981+ }
982+ },
983+ "500": {
984+ "description": "Internal Server Error",
985+ "schema": {
986+ "$ref": "#/definitions/proto.Error"
987+ }
988+ }
989+ }
990+ }
991+ },
992+ "/workspaces/{id}/config/remove": {
993+ "post": {
994+ "consumes": [
995+ "application/json"
996+ ],
997+ "tags": [
998+ "config"
999+ ],
1000+ "summary": "Remove a config field",
1001+ "parameters": [
1002+ {
1003+ "type": "string",
1004+ "description": "Workspace ID",
1005+ "name": "id",
1006+ "in": "path",
1007+ "required": true
1008+ },
1009+ {
1010+ "description": "Config remove request",
1011+ "name": "request",
1012+ "in": "body",
1013+ "required": true,
1014+ "schema": {
1015+ "$ref": "#/definitions/proto.ConfigRemoveRequest"
1016+ }
1017+ }
1018+ ],
1019+ "responses": {
1020+ "200": {
1021+ "description": "OK"
1022+ },
1023+ "400": {
1024+ "description": "Bad Request",
1025+ "schema": {
1026+ "$ref": "#/definitions/proto.Error"
1027+ }
1028+ },
1029+ "404": {
1030+ "description": "Not Found",
1031+ "schema": {
1032+ "$ref": "#/definitions/proto.Error"
1033+ }
1034+ },
1035+ "500": {
1036+ "description": "Internal Server Error",
1037+ "schema": {
1038+ "$ref": "#/definitions/proto.Error"
1039+ }
1040+ }
1041+ }
1042+ }
1043+ },
1044+ "/workspaces/{id}/config/set": {
1045+ "post": {
1046+ "consumes": [
1047+ "application/json"
1048+ ],
1049+ "tags": [
1050+ "config"
1051+ ],
1052+ "summary": "Set a config field",
1053+ "parameters": [
1054+ {
1055+ "type": "string",
1056+ "description": "Workspace ID",
1057+ "name": "id",
1058+ "in": "path",
1059+ "required": true
1060+ },
1061+ {
1062+ "description": "Config set request",
1063+ "name": "request",
1064+ "in": "body",
1065+ "required": true,
1066+ "schema": {
1067+ "$ref": "#/definitions/proto.ConfigSetRequest"
1068+ }
1069+ }
1070+ ],
1071+ "responses": {
1072+ "200": {
1073+ "description": "OK"
1074+ },
1075+ "400": {
1076+ "description": "Bad Request",
1077+ "schema": {
1078+ "$ref": "#/definitions/proto.Error"
1079+ }
1080+ },
1081+ "404": {
1082+ "description": "Not Found",
1083+ "schema": {
1084+ "$ref": "#/definitions/proto.Error"
1085+ }
1086+ },
1087+ "500": {
1088+ "description": "Internal Server Error",
1089+ "schema": {
1090+ "$ref": "#/definitions/proto.Error"
1091+ }
1092+ }
1093+ }
1094+ }
1095+ },
1096+ "/workspaces/{id}/events": {
1097+ "get": {
1098+ "produces": [
1099+ "text/event-stream"
1100+ ],
1101+ "tags": [
1102+ "workspaces"
1103+ ],
1104+ "summary": "Stream workspace events (SSE)",
1105+ "parameters": [
1106+ {
1107+ "type": "string",
1108+ "description": "Workspace ID",
1109+ "name": "id",
1110+ "in": "path",
1111+ "required": true
1112+ }
1113+ ],
1114+ "responses": {
1115+ "200": {
1116+ "description": "OK"
1117+ },
1118+ "404": {
1119+ "description": "Not Found",
1120+ "schema": {
1121+ "$ref": "#/definitions/proto.Error"
1122+ }
1123+ },
1124+ "500": {
1125+ "description": "Internal Server Error",
1126+ "schema": {
1127+ "$ref": "#/definitions/proto.Error"
1128+ }
1129+ }
1130+ }
1131+ }
1132+ },
1133+ "/workspaces/{id}/filetracker/lastread": {
1134+ "get": {
1135+ "produces": [
1136+ "application/json"
1137+ ],
1138+ "tags": [
1139+ "filetracker"
1140+ ],
1141+ "summary": "Get last read time for file",
1142+ "parameters": [
1143+ {
1144+ "type": "string",
1145+ "description": "Workspace ID",
1146+ "name": "id",
1147+ "in": "path",
1148+ "required": true
1149+ },
1150+ {
1151+ "type": "string",
1152+ "description": "Session ID",
1153+ "name": "session_id",
1154+ "in": "query"
1155+ },
1156+ {
1157+ "type": "string",
1158+ "description": "File path",
1159+ "name": "path",
1160+ "in": "query",
1161+ "required": true
1162+ }
1163+ ],
1164+ "responses": {
1165+ "200": {
1166+ "description": "OK",
1167+ "schema": {
1168+ "type": "object"
1169+ }
1170+ },
1171+ "404": {
1172+ "description": "Not Found",
1173+ "schema": {
1174+ "$ref": "#/definitions/proto.Error"
1175+ }
1176+ },
1177+ "500": {
1178+ "description": "Internal Server Error",
1179+ "schema": {
1180+ "$ref": "#/definitions/proto.Error"
1181+ }
1182+ }
1183+ }
1184+ }
1185+ },
1186+ "/workspaces/{id}/filetracker/read": {
1187+ "post": {
1188+ "consumes": [
1189+ "application/json"
1190+ ],
1191+ "tags": [
1192+ "filetracker"
1193+ ],
1194+ "summary": "Record file read",
1195+ "parameters": [
1196+ {
1197+ "type": "string",
1198+ "description": "Workspace ID",
1199+ "name": "id",
1200+ "in": "path",
1201+ "required": true
1202+ },
1203+ {
1204+ "description": "File tracker read request",
1205+ "name": "request",
1206+ "in": "body",
1207+ "required": true,
1208+ "schema": {
1209+ "$ref": "#/definitions/proto.FileTrackerReadRequest"
1210+ }
1211+ }
1212+ ],
1213+ "responses": {
1214+ "200": {
1215+ "description": "OK"
1216+ },
1217+ "400": {
1218+ "description": "Bad Request",
1219+ "schema": {
1220+ "$ref": "#/definitions/proto.Error"
1221+ }
1222+ },
1223+ "404": {
1224+ "description": "Not Found",
1225+ "schema": {
1226+ "$ref": "#/definitions/proto.Error"
1227+ }
1228+ },
1229+ "500": {
1230+ "description": "Internal Server Error",
1231+ "schema": {
1232+ "$ref": "#/definitions/proto.Error"
1233+ }
1234+ }
1235+ }
1236+ }
1237+ },
1238+ "/workspaces/{id}/lsps": {
1239+ "get": {
1240+ "produces": [
1241+ "application/json"
1242+ ],
1243+ "tags": [
1244+ "lsp"
1245+ ],
1246+ "summary": "List LSP clients",
1247+ "parameters": [
1248+ {
1249+ "type": "string",
1250+ "description": "Workspace ID",
1251+ "name": "id",
1252+ "in": "path",
1253+ "required": true
1254+ }
1255+ ],
1256+ "responses": {
1257+ "200": {
1258+ "description": "OK",
1259+ "schema": {
1260+ "type": "object",
1261+ "additionalProperties": {
1262+ "$ref": "#/definitions/proto.LSPClientInfo"
1263+ }
1264+ }
1265+ },
1266+ "404": {
1267+ "description": "Not Found",
1268+ "schema": {
1269+ "$ref": "#/definitions/proto.Error"
1270+ }
1271+ },
1272+ "500": {
1273+ "description": "Internal Server Error",
1274+ "schema": {
1275+ "$ref": "#/definitions/proto.Error"
1276+ }
1277+ }
1278+ }
1279+ }
1280+ },
1281+ "/workspaces/{id}/lsps/start": {
1282+ "post": {
1283+ "consumes": [
1284+ "application/json"
1285+ ],
1286+ "tags": [
1287+ "lsp"
1288+ ],
1289+ "summary": "Start LSP server",
1290+ "parameters": [
1291+ {
1292+ "type": "string",
1293+ "description": "Workspace ID",
1294+ "name": "id",
1295+ "in": "path",
1296+ "required": true
1297+ },
1298+ {
1299+ "description": "LSP start request",
1300+ "name": "request",
1301+ "in": "body",
1302+ "required": true,
1303+ "schema": {
1304+ "$ref": "#/definitions/proto.LSPStartRequest"
1305+ }
1306+ }
1307+ ],
1308+ "responses": {
1309+ "200": {
1310+ "description": "OK"
1311+ },
1312+ "400": {
1313+ "description": "Bad Request",
1314+ "schema": {
1315+ "$ref": "#/definitions/proto.Error"
1316+ }
1317+ },
1318+ "404": {
1319+ "description": "Not Found",
1320+ "schema": {
1321+ "$ref": "#/definitions/proto.Error"
1322+ }
1323+ },
1324+ "500": {
1325+ "description": "Internal Server Error",
1326+ "schema": {
1327+ "$ref": "#/definitions/proto.Error"
1328+ }
1329+ }
1330+ }
1331+ }
1332+ },
1333+ "/workspaces/{id}/lsps/stop": {
1334+ "post": {
1335+ "tags": [
1336+ "lsp"
1337+ ],
1338+ "summary": "Stop all LSP servers",
1339+ "parameters": [
1340+ {
1341+ "type": "string",
1342+ "description": "Workspace ID",
1343+ "name": "id",
1344+ "in": "path",
1345+ "required": true
1346+ }
1347+ ],
1348+ "responses": {
1349+ "200": {
1350+ "description": "OK"
1351+ },
1352+ "404": {
1353+ "description": "Not Found",
1354+ "schema": {
1355+ "$ref": "#/definitions/proto.Error"
1356+ }
1357+ },
1358+ "500": {
1359+ "description": "Internal Server Error",
1360+ "schema": {
1361+ "$ref": "#/definitions/proto.Error"
1362+ }
1363+ }
1364+ }
1365+ }
1366+ },
1367+ "/workspaces/{id}/lsps/{lsp}/diagnostics": {
1368+ "get": {
1369+ "produces": [
1370+ "application/json"
1371+ ],
1372+ "tags": [
1373+ "lsp"
1374+ ],
1375+ "summary": "Get LSP diagnostics",
1376+ "parameters": [
1377+ {
1378+ "type": "string",
1379+ "description": "Workspace ID",
1380+ "name": "id",
1381+ "in": "path",
1382+ "required": true
1383+ },
1384+ {
1385+ "type": "string",
1386+ "description": "LSP client name",
1387+ "name": "lsp",
1388+ "in": "path",
1389+ "required": true
1390+ }
1391+ ],
1392+ "responses": {
1393+ "200": {
1394+ "description": "OK",
1395+ "schema": {
1396+ "type": "object"
1397+ }
1398+ },
1399+ "404": {
1400+ "description": "Not Found",
1401+ "schema": {
1402+ "$ref": "#/definitions/proto.Error"
1403+ }
1404+ },
1405+ "500": {
1406+ "description": "Internal Server Error",
1407+ "schema": {
1408+ "$ref": "#/definitions/proto.Error"
1409+ }
1410+ }
1411+ }
1412+ }
1413+ },
1414+ "/workspaces/{id}/mcp/get-prompt": {
1415+ "post": {
1416+ "consumes": [
1417+ "application/json"
1418+ ],
1419+ "produces": [
1420+ "application/json"
1421+ ],
1422+ "tags": [
1423+ "mcp"
1424+ ],
1425+ "summary": "Get MCP prompt",
1426+ "parameters": [
1427+ {
1428+ "type": "string",
1429+ "description": "Workspace ID",
1430+ "name": "id",
1431+ "in": "path",
1432+ "required": true
1433+ },
1434+ {
1435+ "description": "MCP get prompt request",
1436+ "name": "request",
1437+ "in": "body",
1438+ "required": true,
1439+ "schema": {
1440+ "$ref": "#/definitions/proto.MCPGetPromptRequest"
1441+ }
1442+ }
1443+ ],
1444+ "responses": {
1445+ "200": {
1446+ "description": "OK",
1447+ "schema": {
1448+ "$ref": "#/definitions/proto.MCPGetPromptResponse"
1449+ }
1450+ },
1451+ "400": {
1452+ "description": "Bad Request",
1453+ "schema": {
1454+ "$ref": "#/definitions/proto.Error"
1455+ }
1456+ },
1457+ "404": {
1458+ "description": "Not Found",
1459+ "schema": {
1460+ "$ref": "#/definitions/proto.Error"
1461+ }
1462+ },
1463+ "500": {
1464+ "description": "Internal Server Error",
1465+ "schema": {
1466+ "$ref": "#/definitions/proto.Error"
1467+ }
1468+ }
1469+ }
1470+ }
1471+ },
1472+ "/workspaces/{id}/mcp/read-resource": {
1473+ "post": {
1474+ "consumes": [
1475+ "application/json"
1476+ ],
1477+ "produces": [
1478+ "application/json"
1479+ ],
1480+ "tags": [
1481+ "mcp"
1482+ ],
1483+ "summary": "Read MCP resource",
1484+ "parameters": [
1485+ {
1486+ "type": "string",
1487+ "description": "Workspace ID",
1488+ "name": "id",
1489+ "in": "path",
1490+ "required": true
1491+ },
1492+ {
1493+ "description": "MCP read resource request",
1494+ "name": "request",
1495+ "in": "body",
1496+ "required": true,
1497+ "schema": {
1498+ "$ref": "#/definitions/proto.MCPReadResourceRequest"
1499+ }
1500+ }
1501+ ],
1502+ "responses": {
1503+ "200": {
1504+ "description": "OK",
1505+ "schema": {
1506+ "type": "object"
1507+ }
1508+ },
1509+ "400": {
1510+ "description": "Bad Request",
1511+ "schema": {
1512+ "$ref": "#/definitions/proto.Error"
1513+ }
1514+ },
1515+ "404": {
1516+ "description": "Not Found",
1517+ "schema": {
1518+ "$ref": "#/definitions/proto.Error"
1519+ }
1520+ },
1521+ "500": {
1522+ "description": "Internal Server Error",
1523+ "schema": {
1524+ "$ref": "#/definitions/proto.Error"
1525+ }
1526+ }
1527+ }
1528+ }
1529+ },
1530+ "/workspaces/{id}/mcp/refresh-prompts": {
1531+ "post": {
1532+ "consumes": [
1533+ "application/json"
1534+ ],
1535+ "tags": [
1536+ "mcp"
1537+ ],
1538+ "summary": "Refresh MCP prompts",
1539+ "parameters": [
1540+ {
1541+ "type": "string",
1542+ "description": "Workspace ID",
1543+ "name": "id",
1544+ "in": "path",
1545+ "required": true
1546+ },
1547+ {
1548+ "description": "MCP name request",
1549+ "name": "request",
1550+ "in": "body",
1551+ "required": true,
1552+ "schema": {
1553+ "$ref": "#/definitions/proto.MCPNameRequest"
1554+ }
1555+ }
1556+ ],
1557+ "responses": {
1558+ "200": {
1559+ "description": "OK"
1560+ },
1561+ "400": {
1562+ "description": "Bad Request",
1563+ "schema": {
1564+ "$ref": "#/definitions/proto.Error"
1565+ }
1566+ },
1567+ "404": {
1568+ "description": "Not Found",
1569+ "schema": {
1570+ "$ref": "#/definitions/proto.Error"
1571+ }
1572+ },
1573+ "500": {
1574+ "description": "Internal Server Error",
1575+ "schema": {
1576+ "$ref": "#/definitions/proto.Error"
1577+ }
1578+ }
1579+ }
1580+ }
1581+ },
1582+ "/workspaces/{id}/mcp/refresh-resources": {
1583+ "post": {
1584+ "consumes": [
1585+ "application/json"
1586+ ],
1587+ "tags": [
1588+ "mcp"
1589+ ],
1590+ "summary": "Refresh MCP resources",
1591+ "parameters": [
1592+ {
1593+ "type": "string",
1594+ "description": "Workspace ID",
1595+ "name": "id",
1596+ "in": "path",
1597+ "required": true
1598+ },
1599+ {
1600+ "description": "MCP name request",
1601+ "name": "request",
1602+ "in": "body",
1603+ "required": true,
1604+ "schema": {
1605+ "$ref": "#/definitions/proto.MCPNameRequest"
1606+ }
1607+ }
1608+ ],
1609+ "responses": {
1610+ "200": {
1611+ "description": "OK"
1612+ },
1613+ "400": {
1614+ "description": "Bad Request",
1615+ "schema": {
1616+ "$ref": "#/definitions/proto.Error"
1617+ }
1618+ },
1619+ "404": {
1620+ "description": "Not Found",
1621+ "schema": {
1622+ "$ref": "#/definitions/proto.Error"
1623+ }
1624+ },
1625+ "500": {
1626+ "description": "Internal Server Error",
1627+ "schema": {
1628+ "$ref": "#/definitions/proto.Error"
1629+ }
1630+ }
1631+ }
1632+ }
1633+ },
1634+ "/workspaces/{id}/mcp/refresh-tools": {
1635+ "post": {
1636+ "consumes": [
1637+ "application/json"
1638+ ],
1639+ "tags": [
1640+ "mcp"
1641+ ],
1642+ "summary": "Refresh MCP tools",
1643+ "parameters": [
1644+ {
1645+ "type": "string",
1646+ "description": "Workspace ID",
1647+ "name": "id",
1648+ "in": "path",
1649+ "required": true
1650+ },
1651+ {
1652+ "description": "MCP name request",
1653+ "name": "request",
1654+ "in": "body",
1655+ "required": true,
1656+ "schema": {
1657+ "$ref": "#/definitions/proto.MCPNameRequest"
1658+ }
1659+ }
1660+ ],
1661+ "responses": {
1662+ "200": {
1663+ "description": "OK"
1664+ },
1665+ "400": {
1666+ "description": "Bad Request",
1667+ "schema": {
1668+ "$ref": "#/definitions/proto.Error"
1669+ }
1670+ },
1671+ "404": {
1672+ "description": "Not Found",
1673+ "schema": {
1674+ "$ref": "#/definitions/proto.Error"
1675+ }
1676+ },
1677+ "500": {
1678+ "description": "Internal Server Error",
1679+ "schema": {
1680+ "$ref": "#/definitions/proto.Error"
1681+ }
1682+ }
1683+ }
1684+ }
1685+ },
1686+ "/workspaces/{id}/mcp/states": {
1687+ "get": {
1688+ "produces": [
1689+ "application/json"
1690+ ],
1691+ "tags": [
1692+ "mcp"
1693+ ],
1694+ "summary": "Get MCP client states",
1695+ "parameters": [
1696+ {
1697+ "type": "string",
1698+ "description": "Workspace ID",
1699+ "name": "id",
1700+ "in": "path",
1701+ "required": true
1702+ }
1703+ ],
1704+ "responses": {
1705+ "200": {
1706+ "description": "OK",
1707+ "schema": {
1708+ "type": "object",
1709+ "additionalProperties": {
1710+ "$ref": "#/definitions/proto.MCPClientInfo"
1711+ }
1712+ }
1713+ },
1714+ "404": {
1715+ "description": "Not Found",
1716+ "schema": {
1717+ "$ref": "#/definitions/proto.Error"
1718+ }
1719+ },
1720+ "500": {
1721+ "description": "Internal Server Error",
1722+ "schema": {
1723+ "$ref": "#/definitions/proto.Error"
1724+ }
1725+ }
1726+ }
1727+ }
1728+ },
1729+ "/workspaces/{id}/messages/user": {
1730+ "get": {
1731+ "produces": [
1732+ "application/json"
1733+ ],
1734+ "tags": [
1735+ "workspaces"
1736+ ],
1737+ "summary": "Get all user messages for workspace",
1738+ "parameters": [
1739+ {
1740+ "type": "string",
1741+ "description": "Workspace ID",
1742+ "name": "id",
1743+ "in": "path",
1744+ "required": true
1745+ }
1746+ ],
1747+ "responses": {
1748+ "200": {
1749+ "description": "OK",
1750+ "schema": {
1751+ "type": "array",
1752+ "items": {
1753+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
1754+ }
1755+ }
1756+ },
1757+ "404": {
1758+ "description": "Not Found",
1759+ "schema": {
1760+ "$ref": "#/definitions/proto.Error"
1761+ }
1762+ },
1763+ "500": {
1764+ "description": "Internal Server Error",
1765+ "schema": {
1766+ "$ref": "#/definitions/proto.Error"
1767+ }
1768+ }
1769+ }
1770+ }
1771+ },
1772+ "/workspaces/{id}/permissions/grant": {
1773+ "post": {
1774+ "consumes": [
1775+ "application/json"
1776+ ],
1777+ "tags": [
1778+ "permissions"
1779+ ],
1780+ "summary": "Grant permission",
1781+ "parameters": [
1782+ {
1783+ "type": "string",
1784+ "description": "Workspace ID",
1785+ "name": "id",
1786+ "in": "path",
1787+ "required": true
1788+ },
1789+ {
1790+ "description": "Permission grant",
1791+ "name": "request",
1792+ "in": "body",
1793+ "required": true,
1794+ "schema": {
1795+ "$ref": "#/definitions/proto.PermissionGrant"
1796+ }
1797+ }
1798+ ],
1799+ "responses": {
1800+ "200": {
1801+ "description": "OK"
1802+ },
1803+ "400": {
1804+ "description": "Bad Request",
1805+ "schema": {
1806+ "$ref": "#/definitions/proto.Error"
1807+ }
1808+ },
1809+ "404": {
1810+ "description": "Not Found",
1811+ "schema": {
1812+ "$ref": "#/definitions/proto.Error"
1813+ }
1814+ },
1815+ "500": {
1816+ "description": "Internal Server Error",
1817+ "schema": {
1818+ "$ref": "#/definitions/proto.Error"
1819+ }
1820+ }
1821+ }
1822+ }
1823+ },
1824+ "/workspaces/{id}/permissions/skip": {
1825+ "get": {
1826+ "produces": [
1827+ "application/json"
1828+ ],
1829+ "tags": [
1830+ "permissions"
1831+ ],
1832+ "summary": "Get skip permissions status",
1833+ "parameters": [
1834+ {
1835+ "type": "string",
1836+ "description": "Workspace ID",
1837+ "name": "id",
1838+ "in": "path",
1839+ "required": true
1840+ }
1841+ ],
1842+ "responses": {
1843+ "200": {
1844+ "description": "OK",
1845+ "schema": {
1846+ "$ref": "#/definitions/proto.PermissionSkipRequest"
1847+ }
1848+ },
1849+ "404": {
1850+ "description": "Not Found",
1851+ "schema": {
1852+ "$ref": "#/definitions/proto.Error"
1853+ }
1854+ },
1855+ "500": {
1856+ "description": "Internal Server Error",
1857+ "schema": {
1858+ "$ref": "#/definitions/proto.Error"
1859+ }
1860+ }
1861+ }
1862+ },
1863+ "post": {
1864+ "consumes": [
1865+ "application/json"
1866+ ],
1867+ "tags": [
1868+ "permissions"
1869+ ],
1870+ "summary": "Set skip permissions",
1871+ "parameters": [
1872+ {
1873+ "type": "string",
1874+ "description": "Workspace ID",
1875+ "name": "id",
1876+ "in": "path",
1877+ "required": true
1878+ },
1879+ {
1880+ "description": "Permission skip request",
1881+ "name": "request",
1882+ "in": "body",
1883+ "required": true,
1884+ "schema": {
1885+ "$ref": "#/definitions/proto.PermissionSkipRequest"
1886+ }
1887+ }
1888+ ],
1889+ "responses": {
1890+ "200": {
1891+ "description": "OK"
1892+ },
1893+ "400": {
1894+ "description": "Bad Request",
1895+ "schema": {
1896+ "$ref": "#/definitions/proto.Error"
1897+ }
1898+ },
1899+ "404": {
1900+ "description": "Not Found",
1901+ "schema": {
1902+ "$ref": "#/definitions/proto.Error"
1903+ }
1904+ },
1905+ "500": {
1906+ "description": "Internal Server Error",
1907+ "schema": {
1908+ "$ref": "#/definitions/proto.Error"
1909+ }
1910+ }
1911+ }
1912+ }
1913+ },
1914+ "/workspaces/{id}/project/init": {
1915+ "post": {
1916+ "tags": [
1917+ "project"
1918+ ],
1919+ "summary": "Mark project as initialized",
1920+ "parameters": [
1921+ {
1922+ "type": "string",
1923+ "description": "Workspace ID",
1924+ "name": "id",
1925+ "in": "path",
1926+ "required": true
1927+ }
1928+ ],
1929+ "responses": {
1930+ "200": {
1931+ "description": "OK"
1932+ },
1933+ "404": {
1934+ "description": "Not Found",
1935+ "schema": {
1936+ "$ref": "#/definitions/proto.Error"
1937+ }
1938+ },
1939+ "500": {
1940+ "description": "Internal Server Error",
1941+ "schema": {
1942+ "$ref": "#/definitions/proto.Error"
1943+ }
1944+ }
1945+ }
1946+ }
1947+ },
1948+ "/workspaces/{id}/project/init-prompt": {
1949+ "get": {
1950+ "produces": [
1951+ "application/json"
1952+ ],
1953+ "tags": [
1954+ "project"
1955+ ],
1956+ "summary": "Get project initialization prompt",
1957+ "parameters": [
1958+ {
1959+ "type": "string",
1960+ "description": "Workspace ID",
1961+ "name": "id",
1962+ "in": "path",
1963+ "required": true
1964+ }
1965+ ],
1966+ "responses": {
1967+ "200": {
1968+ "description": "OK",
1969+ "schema": {
1970+ "$ref": "#/definitions/proto.ProjectInitPromptResponse"
1971+ }
1972+ },
1973+ "404": {
1974+ "description": "Not Found",
1975+ "schema": {
1976+ "$ref": "#/definitions/proto.Error"
1977+ }
1978+ },
1979+ "500": {
1980+ "description": "Internal Server Error",
1981+ "schema": {
1982+ "$ref": "#/definitions/proto.Error"
1983+ }
1984+ }
1985+ }
1986+ }
1987+ },
1988+ "/workspaces/{id}/project/needs-init": {
1989+ "get": {
1990+ "produces": [
1991+ "application/json"
1992+ ],
1993+ "tags": [
1994+ "project"
1995+ ],
1996+ "summary": "Check if project needs initialization",
1997+ "parameters": [
1998+ {
1999+ "type": "string",
2000+ "description": "Workspace ID",
2001+ "name": "id",
2002+ "in": "path",
2003+ "required": true
2004+ }
2005+ ],
2006+ "responses": {
2007+ "200": {
2008+ "description": "OK",
2009+ "schema": {
2010+ "$ref": "#/definitions/proto.ProjectNeedsInitResponse"
2011+ }
2012+ },
2013+ "404": {
2014+ "description": "Not Found",
2015+ "schema": {
2016+ "$ref": "#/definitions/proto.Error"
2017+ }
2018+ },
2019+ "500": {
2020+ "description": "Internal Server Error",
2021+ "schema": {
2022+ "$ref": "#/definitions/proto.Error"
2023+ }
2024+ }
2025+ }
2026+ }
2027+ },
2028+ "/workspaces/{id}/providers": {
2029+ "get": {
2030+ "produces": [
2031+ "application/json"
2032+ ],
2033+ "tags": [
2034+ "workspaces"
2035+ ],
2036+ "summary": "Get workspace providers",
2037+ "parameters": [
2038+ {
2039+ "type": "string",
2040+ "description": "Workspace ID",
2041+ "name": "id",
2042+ "in": "path",
2043+ "required": true
2044+ }
2045+ ],
2046+ "responses": {
2047+ "200": {
2048+ "description": "OK",
2049+ "schema": {
2050+ "type": "object"
2051+ }
2052+ },
2053+ "404": {
2054+ "description": "Not Found",
2055+ "schema": {
2056+ "$ref": "#/definitions/proto.Error"
2057+ }
2058+ },
2059+ "500": {
2060+ "description": "Internal Server Error",
2061+ "schema": {
2062+ "$ref": "#/definitions/proto.Error"
2063+ }
2064+ }
2065+ }
2066+ }
2067+ },
2068+ "/workspaces/{id}/sessions": {
2069+ "get": {
2070+ "produces": [
2071+ "application/json"
2072+ ],
2073+ "tags": [
2074+ "sessions"
2075+ ],
2076+ "summary": "List sessions",
2077+ "parameters": [
2078+ {
2079+ "type": "string",
2080+ "description": "Workspace ID",
2081+ "name": "id",
2082+ "in": "path",
2083+ "required": true
2084+ }
2085+ ],
2086+ "responses": {
2087+ "200": {
2088+ "description": "OK",
2089+ "schema": {
2090+ "type": "array",
2091+ "items": {
2092+ "$ref": "#/definitions/proto.Session"
2093+ }
2094+ }
2095+ },
2096+ "404": {
2097+ "description": "Not Found",
2098+ "schema": {
2099+ "$ref": "#/definitions/proto.Error"
2100+ }
2101+ },
2102+ "500": {
2103+ "description": "Internal Server Error",
2104+ "schema": {
2105+ "$ref": "#/definitions/proto.Error"
2106+ }
2107+ }
2108+ }
2109+ },
2110+ "post": {
2111+ "consumes": [
2112+ "application/json"
2113+ ],
2114+ "produces": [
2115+ "application/json"
2116+ ],
2117+ "tags": [
2118+ "sessions"
2119+ ],
2120+ "summary": "Create session",
2121+ "parameters": [
2122+ {
2123+ "type": "string",
2124+ "description": "Workspace ID",
2125+ "name": "id",
2126+ "in": "path",
2127+ "required": true
2128+ },
2129+ {
2130+ "description": "Session creation params (title)",
2131+ "name": "request",
2132+ "in": "body",
2133+ "required": true,
2134+ "schema": {
2135+ "$ref": "#/definitions/proto.Session"
2136+ }
2137+ }
2138+ ],
2139+ "responses": {
2140+ "200": {
2141+ "description": "OK",
2142+ "schema": {
2143+ "$ref": "#/definitions/proto.Session"
2144+ }
2145+ },
2146+ "400": {
2147+ "description": "Bad Request",
2148+ "schema": {
2149+ "$ref": "#/definitions/proto.Error"
2150+ }
2151+ },
2152+ "404": {
2153+ "description": "Not Found",
2154+ "schema": {
2155+ "$ref": "#/definitions/proto.Error"
2156+ }
2157+ },
2158+ "500": {
2159+ "description": "Internal Server Error",
2160+ "schema": {
2161+ "$ref": "#/definitions/proto.Error"
2162+ }
2163+ }
2164+ }
2165+ }
2166+ },
2167+ "/workspaces/{id}/sessions/{sid}": {
2168+ "get": {
2169+ "produces": [
2170+ "application/json"
2171+ ],
2172+ "tags": [
2173+ "sessions"
2174+ ],
2175+ "summary": "Get session",
2176+ "parameters": [
2177+ {
2178+ "type": "string",
2179+ "description": "Workspace ID",
2180+ "name": "id",
2181+ "in": "path",
2182+ "required": true
2183+ },
2184+ {
2185+ "type": "string",
2186+ "description": "Session ID",
2187+ "name": "sid",
2188+ "in": "path",
2189+ "required": true
2190+ }
2191+ ],
2192+ "responses": {
2193+ "200": {
2194+ "description": "OK",
2195+ "schema": {
2196+ "$ref": "#/definitions/proto.Session"
2197+ }
2198+ },
2199+ "404": {
2200+ "description": "Not Found",
2201+ "schema": {
2202+ "$ref": "#/definitions/proto.Error"
2203+ }
2204+ },
2205+ "500": {
2206+ "description": "Internal Server Error",
2207+ "schema": {
2208+ "$ref": "#/definitions/proto.Error"
2209+ }
2210+ }
2211+ }
2212+ },
2213+ "put": {
2214+ "consumes": [
2215+ "application/json"
2216+ ],
2217+ "produces": [
2218+ "application/json"
2219+ ],
2220+ "tags": [
2221+ "sessions"
2222+ ],
2223+ "summary": "Update session",
2224+ "parameters": [
2225+ {
2226+ "type": "string",
2227+ "description": "Workspace ID",
2228+ "name": "id",
2229+ "in": "path",
2230+ "required": true
2231+ },
2232+ {
2233+ "type": "string",
2234+ "description": "Session ID",
2235+ "name": "sid",
2236+ "in": "path",
2237+ "required": true
2238+ },
2239+ {
2240+ "description": "Updated session",
2241+ "name": "request",
2242+ "in": "body",
2243+ "required": true,
2244+ "schema": {
2245+ "$ref": "#/definitions/proto.Session"
2246+ }
2247+ }
2248+ ],
2249+ "responses": {
2250+ "200": {
2251+ "description": "OK",
2252+ "schema": {
2253+ "$ref": "#/definitions/proto.Session"
2254+ }
2255+ },
2256+ "400": {
2257+ "description": "Bad Request",
2258+ "schema": {
2259+ "$ref": "#/definitions/proto.Error"
2260+ }
2261+ },
2262+ "404": {
2263+ "description": "Not Found",
2264+ "schema": {
2265+ "$ref": "#/definitions/proto.Error"
2266+ }
2267+ },
2268+ "500": {
2269+ "description": "Internal Server Error",
2270+ "schema": {
2271+ "$ref": "#/definitions/proto.Error"
2272+ }
2273+ }
2274+ }
2275+ },
2276+ "delete": {
2277+ "tags": [
2278+ "sessions"
2279+ ],
2280+ "summary": "Delete session",
2281+ "parameters": [
2282+ {
2283+ "type": "string",
2284+ "description": "Workspace ID",
2285+ "name": "id",
2286+ "in": "path",
2287+ "required": true
2288+ },
2289+ {
2290+ "type": "string",
2291+ "description": "Session ID",
2292+ "name": "sid",
2293+ "in": "path",
2294+ "required": true
2295+ }
2296+ ],
2297+ "responses": {
2298+ "200": {
2299+ "description": "OK"
2300+ },
2301+ "404": {
2302+ "description": "Not Found",
2303+ "schema": {
2304+ "$ref": "#/definitions/proto.Error"
2305+ }
2306+ },
2307+ "500": {
2308+ "description": "Internal Server Error",
2309+ "schema": {
2310+ "$ref": "#/definitions/proto.Error"
2311+ }
2312+ }
2313+ }
2314+ }
2315+ },
2316+ "/workspaces/{id}/sessions/{sid}/filetracker/files": {
2317+ "get": {
2318+ "produces": [
2319+ "application/json"
2320+ ],
2321+ "tags": [
2322+ "filetracker"
2323+ ],
2324+ "summary": "List tracked files for session",
2325+ "parameters": [
2326+ {
2327+ "type": "string",
2328+ "description": "Workspace ID",
2329+ "name": "id",
2330+ "in": "path",
2331+ "required": true
2332+ },
2333+ {
2334+ "type": "string",
2335+ "description": "Session ID",
2336+ "name": "sid",
2337+ "in": "path",
2338+ "required": true
2339+ }
2340+ ],
2341+ "responses": {
2342+ "200": {
2343+ "description": "OK",
2344+ "schema": {
2345+ "type": "array",
2346+ "items": {
2347+ "type": "string"
2348+ }
2349+ }
2350+ },
2351+ "404": {
2352+ "description": "Not Found",
2353+ "schema": {
2354+ "$ref": "#/definitions/proto.Error"
2355+ }
2356+ },
2357+ "500": {
2358+ "description": "Internal Server Error",
2359+ "schema": {
2360+ "$ref": "#/definitions/proto.Error"
2361+ }
2362+ }
2363+ }
2364+ }
2365+ },
2366+ "/workspaces/{id}/sessions/{sid}/history": {
2367+ "get": {
2368+ "produces": [
2369+ "application/json"
2370+ ],
2371+ "tags": [
2372+ "sessions"
2373+ ],
2374+ "summary": "Get session history",
2375+ "parameters": [
2376+ {
2377+ "type": "string",
2378+ "description": "Workspace ID",
2379+ "name": "id",
2380+ "in": "path",
2381+ "required": true
2382+ },
2383+ {
2384+ "type": "string",
2385+ "description": "Session ID",
2386+ "name": "sid",
2387+ "in": "path",
2388+ "required": true
2389+ }
2390+ ],
2391+ "responses": {
2392+ "200": {
2393+ "description": "OK",
2394+ "schema": {
2395+ "type": "array",
2396+ "items": {
2397+ "$ref": "#/definitions/proto.File"
2398+ }
2399+ }
2400+ },
2401+ "404": {
2402+ "description": "Not Found",
2403+ "schema": {
2404+ "$ref": "#/definitions/proto.Error"
2405+ }
2406+ },
2407+ "500": {
2408+ "description": "Internal Server Error",
2409+ "schema": {
2410+ "$ref": "#/definitions/proto.Error"
2411+ }
2412+ }
2413+ }
2414+ }
2415+ },
2416+ "/workspaces/{id}/sessions/{sid}/messages": {
2417+ "get": {
2418+ "produces": [
2419+ "application/json"
2420+ ],
2421+ "tags": [
2422+ "sessions"
2423+ ],
2424+ "summary": "Get session messages",
2425+ "parameters": [
2426+ {
2427+ "type": "string",
2428+ "description": "Workspace ID",
2429+ "name": "id",
2430+ "in": "path",
2431+ "required": true
2432+ },
2433+ {
2434+ "type": "string",
2435+ "description": "Session ID",
2436+ "name": "sid",
2437+ "in": "path",
2438+ "required": true
2439+ }
2440+ ],
2441+ "responses": {
2442+ "200": {
2443+ "description": "OK",
2444+ "schema": {
2445+ "type": "array",
2446+ "items": {
2447+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
2448+ }
2449+ }
2450+ },
2451+ "404": {
2452+ "description": "Not Found",
2453+ "schema": {
2454+ "$ref": "#/definitions/proto.Error"
2455+ }
2456+ },
2457+ "500": {
2458+ "description": "Internal Server Error",
2459+ "schema": {
2460+ "$ref": "#/definitions/proto.Error"
2461+ }
2462+ }
2463+ }
2464+ }
2465+ },
2466+ "/workspaces/{id}/sessions/{sid}/messages/user": {
2467+ "get": {
2468+ "produces": [
2469+ "application/json"
2470+ ],
2471+ "tags": [
2472+ "sessions"
2473+ ],
2474+ "summary": "Get user messages for session",
2475+ "parameters": [
2476+ {
2477+ "type": "string",
2478+ "description": "Workspace ID",
2479+ "name": "id",
2480+ "in": "path",
2481+ "required": true
2482+ },
2483+ {
2484+ "type": "string",
2485+ "description": "Session ID",
2486+ "name": "sid",
2487+ "in": "path",
2488+ "required": true
2489+ }
2490+ ],
2491+ "responses": {
2492+ "200": {
2493+ "description": "OK",
2494+ "schema": {
2495+ "type": "array",
2496+ "items": {
2497+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
2498+ }
2499+ }
2500+ },
2501+ "404": {
2502+ "description": "Not Found",
2503+ "schema": {
2504+ "$ref": "#/definitions/proto.Error"
2505+ }
2506+ },
2507+ "500": {
2508+ "description": "Internal Server Error",
2509+ "schema": {
2510+ "$ref": "#/definitions/proto.Error"
2511+ }
2512+ }
2513+ }
2514+ }
2515+ }
2516+ },
2517+ "definitions": {
2518+ "catwalk.Model": {
2519+ "type": "object",
2520+ "properties": {
2521+ "can_reason": {
2522+ "type": "boolean"
2523+ },
2524+ "context_window": {
2525+ "type": "integer"
2526+ },
2527+ "cost_per_1m_in": {
2528+ "type": "number"
2529+ },
2530+ "cost_per_1m_in_cached": {
2531+ "type": "number"
2532+ },
2533+ "cost_per_1m_out": {
2534+ "type": "number"
2535+ },
2536+ "cost_per_1m_out_cached": {
2537+ "type": "number"
2538+ },
2539+ "default_max_tokens": {
2540+ "type": "integer"
2541+ },
2542+ "default_reasoning_effort": {
2543+ "type": "string"
2544+ },
2545+ "id": {
2546+ "type": "string"
2547+ },
2548+ "name": {
2549+ "type": "string"
2550+ },
2551+ "options": {
2552+ "$ref": "#/definitions/catwalk.ModelOptions"
2553+ },
2554+ "reasoning_levels": {
2555+ "type": "array",
2556+ "items": {
2557+ "type": "string"
2558+ }
2559+ },
2560+ "supports_attachments": {
2561+ "type": "boolean"
2562+ }
2563+ }
2564+ },
2565+ "catwalk.ModelOptions": {
2566+ "type": "object",
2567+ "properties": {
2568+ "frequency_penalty": {
2569+ "type": "number"
2570+ },
2571+ "presence_penalty": {
2572+ "type": "number"
2573+ },
2574+ "provider_options": {
2575+ "type": "object",
2576+ "additionalProperties": {}
2577+ },
2578+ "temperature": {
2579+ "type": "number"
2580+ },
2581+ "top_k": {
2582+ "type": "integer"
2583+ },
2584+ "top_p": {
2585+ "type": "number"
2586+ }
2587+ }
2588+ },
2589+ "config.Attribution": {
2590+ "type": "object",
2591+ "properties": {
2592+ "co_authored_by": {
2593+ "type": "boolean"
2594+ },
2595+ "generated_with": {
2596+ "type": "boolean"
2597+ },
2598+ "trailer_style": {
2599+ "$ref": "#/definitions/config.TrailerStyle"
2600+ }
2601+ }
2602+ },
2603+ "config.Completions": {
2604+ "type": "object",
2605+ "properties": {
2606+ "max_depth": {
2607+ "type": "integer"
2608+ },
2609+ "max_items": {
2610+ "type": "integer"
2611+ }
2612+ }
2613+ },
2614+ "config.LSPConfig": {
2615+ "type": "object",
2616+ "properties": {
2617+ "args": {
2618+ "type": "array",
2619+ "items": {
2620+ "type": "string"
2621+ }
2622+ },
2623+ "command": {
2624+ "type": "string"
2625+ },
2626+ "disabled": {
2627+ "type": "boolean"
2628+ },
2629+ "env": {
2630+ "type": "object",
2631+ "additionalProperties": {
2632+ "type": "string"
2633+ }
2634+ },
2635+ "filetypes": {
2636+ "type": "array",
2637+ "items": {
2638+ "type": "string"
2639+ }
2640+ },
2641+ "init_options": {
2642+ "type": "object",
2643+ "additionalProperties": {}
2644+ },
2645+ "options": {
2646+ "type": "object",
2647+ "additionalProperties": {}
2648+ },
2649+ "root_markers": {
2650+ "type": "array",
2651+ "items": {
2652+ "type": "string"
2653+ }
2654+ },
2655+ "timeout": {
2656+ "type": "integer"
2657+ }
2658+ }
2659+ },
2660+ "config.LSPs": {
2661+ "type": "object",
2662+ "additionalProperties": {
2663+ "$ref": "#/definitions/config.LSPConfig"
2664+ }
2665+ },
2666+ "config.MCPConfig": {
2667+ "type": "object",
2668+ "properties": {
2669+ "args": {
2670+ "type": "array",
2671+ "items": {
2672+ "type": "string"
2673+ }
2674+ },
2675+ "command": {
2676+ "type": "string"
2677+ },
2678+ "disabled": {
2679+ "type": "boolean"
2680+ },
2681+ "disabled_tools": {
2682+ "type": "array",
2683+ "items": {
2684+ "type": "string"
2685+ }
2686+ },
2687+ "env": {
2688+ "type": "object",
2689+ "additionalProperties": {
2690+ "type": "string"
2691+ }
2692+ },
2693+ "headers": {
2694+ "description": "TODO: maybe make it possible to get the value from the env",
2695+ "type": "object",
2696+ "additionalProperties": {
2697+ "type": "string"
2698+ }
2699+ },
2700+ "timeout": {
2701+ "type": "integer"
2702+ },
2703+ "type": {
2704+ "$ref": "#/definitions/config.MCPType"
2705+ },
2706+ "url": {
2707+ "type": "string"
2708+ }
2709+ }
2710+ },
2711+ "config.MCPType": {
2712+ "type": "string",
2713+ "enum": [
2714+ "stdio",
2715+ "sse",
2716+ "http"
2717+ ],
2718+ "x-enum-varnames": [
2719+ "MCPStdio",
2720+ "MCPSSE",
2721+ "MCPHttp"
2722+ ]
2723+ },
2724+ "config.MCPs": {
2725+ "type": "object",
2726+ "additionalProperties": {
2727+ "$ref": "#/definitions/config.MCPConfig"
2728+ }
2729+ },
2730+ "config.Permissions": {
2731+ "type": "object",
2732+ "properties": {
2733+ "allowed_tools": {
2734+ "type": "array",
2735+ "items": {
2736+ "type": "string"
2737+ }
2738+ }
2739+ }
2740+ },
2741+ "config.Scope": {
2742+ "type": "integer",
2743+ "enum": [
2744+ 0,
2745+ 1
2746+ ],
2747+ "x-enum-varnames": [
2748+ "ScopeGlobal",
2749+ "ScopeWorkspace"
2750+ ]
2751+ },
2752+ "config.SelectedModel": {
2753+ "type": "object",
2754+ "properties": {
2755+ "frequency_penalty": {
2756+ "type": "number"
2757+ },
2758+ "max_tokens": {
2759+ "description": "Overrides the default model configuration.",
2760+ "type": "integer"
2761+ },
2762+ "model": {
2763+ "description": "The model id as used by the provider API.\nRequired.",
2764+ "type": "string"
2765+ },
2766+ "presence_penalty": {
2767+ "type": "number"
2768+ },
2769+ "provider": {
2770+ "description": "The model provider, same as the key/id used in the providers config.\nRequired.",
2771+ "type": "string"
2772+ },
2773+ "provider_options": {
2774+ "description": "Override provider specific options.",
2775+ "type": "object",
2776+ "additionalProperties": {}
2777+ },
2778+ "reasoning_effort": {
2779+ "description": "Only used by models that use the openai provider and need this set.",
2780+ "type": "string"
2781+ },
2782+ "temperature": {
2783+ "type": "number"
2784+ },
2785+ "think": {
2786+ "description": "Used by anthropic models that can reason to indicate if the model should think.",
2787+ "type": "boolean"
2788+ },
2789+ "top_k": {
2790+ "type": "integer"
2791+ },
2792+ "top_p": {
2793+ "type": "number"
2794+ }
2795+ }
2796+ },
2797+ "config.SelectedModelType": {
2798+ "type": "string",
2799+ "enum": [
2800+ "large",
2801+ "small"
2802+ ],
2803+ "x-enum-varnames": [
2804+ "SelectedModelTypeLarge",
2805+ "SelectedModelTypeSmall"
2806+ ]
2807+ },
2808+ "config.TUIOptions": {
2809+ "type": "object",
2810+ "properties": {
2811+ "compact_mode": {
2812+ "type": "boolean"
2813+ },
2814+ "completions": {
2815+ "$ref": "#/definitions/config.Completions"
2816+ },
2817+ "diff_mode": {
2818+ "type": "string"
2819+ },
2820+ "transparent": {
2821+ "type": "boolean"
2822+ }
2823+ }
2824+ },
2825+ "config.ToolGrep": {
2826+ "type": "object",
2827+ "properties": {
2828+ "timeout": {
2829+ "$ref": "#/definitions/time.Duration"
2830+ }
2831+ }
2832+ },
2833+ "config.ToolLs": {
2834+ "type": "object",
2835+ "properties": {
2836+ "max_depth": {
2837+ "type": "integer"
2838+ },
2839+ "max_items": {
2840+ "type": "integer"
2841+ }
2842+ }
2843+ },
2844+ "config.Tools": {
2845+ "type": "object",
2846+ "properties": {
2847+ "grep": {
2848+ "$ref": "#/definitions/config.ToolGrep"
2849+ },
2850+ "ls": {
2851+ "$ref": "#/definitions/config.ToolLs"
2852+ }
2853+ }
2854+ },
2855+ "config.TrailerStyle": {
2856+ "type": "string",
2857+ "enum": [
2858+ "none",
2859+ "co-authored-by",
2860+ "assisted-by"
2861+ ],
2862+ "x-enum-varnames": [
2863+ "TrailerStyleNone",
2864+ "TrailerStyleCoAuthoredBy",
2865+ "TrailerStyleAssistedBy"
2866+ ]
2867+ },
2868+ "csync.Map-string-config_ProviderConfig": {
2869+ "type": "object"
2870+ },
2871+ "github_com_charmbracelet_crush_internal_config.Config": {
2872+ "type": "object",
2873+ "properties": {
2874+ "$schema": {
2875+ "type": "string"
2876+ },
2877+ "lsp": {
2878+ "$ref": "#/definitions/config.LSPs"
2879+ },
2880+ "mcp": {
2881+ "$ref": "#/definitions/config.MCPs"
2882+ },
2883+ "models": {
2884+ "description": "We currently only support large/small as values here.",
2885+ "type": "object",
2886+ "additionalProperties": {
2887+ "$ref": "#/definitions/config.SelectedModel"
2888+ }
2889+ },
2890+ "options": {
2891+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_config.Options"
2892+ },
2893+ "permissions": {
2894+ "$ref": "#/definitions/config.Permissions"
2895+ },
2896+ "providers": {
2897+ "description": "The providers that are configured",
2898+ "allOf": [
2899+ {
2900+ "$ref": "#/definitions/csync.Map-string-config_ProviderConfig"
2901+ }
2902+ ]
2903+ },
2904+ "recent_models": {
2905+ "description": "Recently used models stored in the data directory config.",
2906+ "type": "object",
2907+ "additionalProperties": {
2908+ "type": "array",
2909+ "items": {
2910+ "$ref": "#/definitions/config.SelectedModel"
2911+ }
2912+ }
2913+ },
2914+ "tools": {
2915+ "$ref": "#/definitions/config.Tools"
2916+ }
2917+ }
2918+ },
2919+ "github_com_charmbracelet_crush_internal_config.Options": {
2920+ "type": "object",
2921+ "properties": {
2922+ "attribution": {
2923+ "$ref": "#/definitions/config.Attribution"
2924+ },
2925+ "auto_lsp": {
2926+ "type": "boolean"
2927+ },
2928+ "context_paths": {
2929+ "type": "array",
2930+ "items": {
2931+ "type": "string"
2932+ }
2933+ },
2934+ "data_directory": {
2935+ "description": "Relative to the cwd",
2936+ "type": "string"
2937+ },
2938+ "debug": {
2939+ "type": "boolean"
2940+ },
2941+ "debug_lsp": {
2942+ "type": "boolean"
2943+ },
2944+ "disable_auto_summarize": {
2945+ "type": "boolean"
2946+ },
2947+ "disable_default_providers": {
2948+ "type": "boolean"
2949+ },
2950+ "disable_metrics": {
2951+ "type": "boolean"
2952+ },
2953+ "disable_notifications": {
2954+ "type": "boolean"
2955+ },
2956+ "disable_provider_auto_update": {
2957+ "type": "boolean"
2958+ },
2959+ "disabled_tools": {
2960+ "type": "array",
2961+ "items": {
2962+ "type": "string"
2963+ }
2964+ },
2965+ "initialize_as": {
2966+ "type": "string"
2967+ },
2968+ "progress": {
2969+ "type": "boolean"
2970+ },
2971+ "skills_paths": {
2972+ "type": "array",
2973+ "items": {
2974+ "type": "string"
2975+ }
2976+ },
2977+ "tui": {
2978+ "$ref": "#/definitions/config.TUIOptions"
2979+ }
2980+ }
2981+ },
2982+ "github_com_charmbracelet_crush_internal_proto.Message": {
2983+ "type": "object",
2984+ "properties": {
2985+ "created_at": {
2986+ "type": "integer"
2987+ },
2988+ "id": {
2989+ "type": "string"
2990+ },
2991+ "model": {
2992+ "type": "string"
2993+ },
2994+ "parts": {
2995+ "type": "array",
2996+ "items": {}
2997+ },
2998+ "provider": {
2999+ "type": "string"
3000+ },
3001+ "role": {
3002+ "$ref": "#/definitions/proto.MessageRole"
3003+ },
3004+ "session_id": {
3005+ "type": "string"
3006+ },
3007+ "updated_at": {
3008+ "type": "integer"
3009+ }
3010+ }
3011+ },
3012+ "lsp.ServerState": {
3013+ "type": "integer",
3014+ "enum": [
3015+ 0,
3016+ 1,
3017+ 2,
3018+ 3,
3019+ 4,
3020+ 5
3021+ ],
3022+ "x-enum-varnames": [
3023+ "StateUnstarted",
3024+ "StateStarting",
3025+ "StateReady",
3026+ "StateError",
3027+ "StateStopped",
3028+ "StateDisabled"
3029+ ]
3030+ },
3031+ "proto.AgentInfo": {
3032+ "type": "object",
3033+ "properties": {
3034+ "is_busy": {
3035+ "type": "boolean"
3036+ },
3037+ "is_ready": {
3038+ "type": "boolean"
3039+ },
3040+ "model": {
3041+ "$ref": "#/definitions/catwalk.Model"
3042+ },
3043+ "model_cfg": {
3044+ "$ref": "#/definitions/config.SelectedModel"
3045+ }
3046+ }
3047+ },
3048+ "proto.AgentMessage": {
3049+ "type": "object",
3050+ "properties": {
3051+ "attachments": {
3052+ "type": "array",
3053+ "items": {
3054+ "$ref": "#/definitions/proto.Attachment"
3055+ }
3056+ },
3057+ "prompt": {
3058+ "type": "string"
3059+ },
3060+ "session_id": {
3061+ "type": "string"
3062+ }
3063+ }
3064+ },
3065+ "proto.AgentSession": {
3066+ "type": "object",
3067+ "properties": {
3068+ "completion_tokens": {
3069+ "type": "integer"
3070+ },
3071+ "cost": {
3072+ "type": "number"
3073+ },
3074+ "created_at": {
3075+ "type": "integer"
3076+ },
3077+ "id": {
3078+ "type": "string"
3079+ },
3080+ "is_busy": {
3081+ "type": "boolean"
3082+ },
3083+ "message_count": {
3084+ "type": "integer"
3085+ },
3086+ "parent_session_id": {
3087+ "type": "string"
3088+ },
3089+ "prompt_tokens": {
3090+ "type": "integer"
3091+ },
3092+ "summary_message_id": {
3093+ "type": "string"
3094+ },
3095+ "title": {
3096+ "type": "string"
3097+ },
3098+ "updated_at": {
3099+ "type": "integer"
3100+ }
3101+ }
3102+ },
3103+ "proto.Attachment": {
3104+ "type": "object",
3105+ "properties": {
3106+ "content": {
3107+ "type": "array",
3108+ "items": {
3109+ "type": "integer"
3110+ }
3111+ },
3112+ "file_name": {
3113+ "type": "string"
3114+ },
3115+ "file_path": {
3116+ "type": "string"
3117+ },
3118+ "mime_type": {
3119+ "type": "string"
3120+ }
3121+ }
3122+ },
3123+ "proto.ConfigCompactRequest": {
3124+ "type": "object",
3125+ "properties": {
3126+ "enabled": {
3127+ "type": "boolean"
3128+ },
3129+ "scope": {
3130+ "$ref": "#/definitions/config.Scope"
3131+ }
3132+ }
3133+ },
3134+ "proto.ConfigModelRequest": {
3135+ "type": "object",
3136+ "properties": {
3137+ "model": {
3138+ "$ref": "#/definitions/config.SelectedModel"
3139+ },
3140+ "model_type": {
3141+ "$ref": "#/definitions/config.SelectedModelType"
3142+ },
3143+ "scope": {
3144+ "$ref": "#/definitions/config.Scope"
3145+ }
3146+ }
3147+ },
3148+ "proto.ConfigProviderKeyRequest": {
3149+ "type": "object",
3150+ "properties": {
3151+ "api_key": {},
3152+ "provider_id": {
3153+ "type": "string"
3154+ },
3155+ "scope": {
3156+ "$ref": "#/definitions/config.Scope"
3157+ }
3158+ }
3159+ },
3160+ "proto.ConfigRefreshOAuthRequest": {
3161+ "type": "object",
3162+ "properties": {
3163+ "provider_id": {
3164+ "type": "string"
3165+ },
3166+ "scope": {
3167+ "$ref": "#/definitions/config.Scope"
3168+ }
3169+ }
3170+ },
3171+ "proto.ConfigRemoveRequest": {
3172+ "type": "object",
3173+ "properties": {
3174+ "key": {
3175+ "type": "string"
3176+ },
3177+ "scope": {
3178+ "$ref": "#/definitions/config.Scope"
3179+ }
3180+ }
3181+ },
3182+ "proto.ConfigSetRequest": {
3183+ "type": "object",
3184+ "properties": {
3185+ "key": {
3186+ "type": "string"
3187+ },
3188+ "scope": {
3189+ "$ref": "#/definitions/config.Scope"
3190+ },
3191+ "value": {}
3192+ }
3193+ },
3194+ "proto.Error": {
3195+ "type": "object",
3196+ "properties": {
3197+ "message": {
3198+ "type": "string"
3199+ }
3200+ }
3201+ },
3202+ "proto.File": {
3203+ "type": "object",
3204+ "properties": {
3205+ "content": {
3206+ "type": "string"
3207+ },
3208+ "created_at": {
3209+ "type": "integer"
3210+ },
3211+ "id": {
3212+ "type": "string"
3213+ },
3214+ "path": {
3215+ "type": "string"
3216+ },
3217+ "session_id": {
3218+ "type": "string"
3219+ },
3220+ "updated_at": {
3221+ "type": "integer"
3222+ },
3223+ "version": {
3224+ "type": "integer"
3225+ }
3226+ }
3227+ },
3228+ "proto.FileTrackerReadRequest": {
3229+ "type": "object",
3230+ "properties": {
3231+ "path": {
3232+ "type": "string"
3233+ },
3234+ "session_id": {
3235+ "type": "string"
3236+ }
3237+ }
3238+ },
3239+ "proto.ImportCopilotResponse": {
3240+ "type": "object",
3241+ "properties": {
3242+ "success": {
3243+ "type": "boolean"
3244+ },
3245+ "token": {}
3246+ }
3247+ },
3248+ "proto.LSPClientInfo": {
3249+ "type": "object",
3250+ "properties": {
3251+ "connected_at": {
3252+ "type": "string"
3253+ },
3254+ "diagnostic_count": {
3255+ "type": "integer"
3256+ },
3257+ "error": {},
3258+ "name": {
3259+ "type": "string"
3260+ },
3261+ "state": {
3262+ "$ref": "#/definitions/lsp.ServerState"
3263+ }
3264+ }
3265+ },
3266+ "proto.LSPStartRequest": {
3267+ "type": "object",
3268+ "properties": {
3269+ "path": {
3270+ "type": "string"
3271+ }
3272+ }
3273+ },
3274+ "proto.MCPClientInfo": {
3275+ "type": "object",
3276+ "properties": {
3277+ "connected_at": {
3278+ "type": "string"
3279+ },
3280+ "error": {},
3281+ "name": {
3282+ "type": "string"
3283+ },
3284+ "prompt_count": {
3285+ "type": "integer"
3286+ },
3287+ "resource_count": {
3288+ "type": "integer"
3289+ },
3290+ "state": {
3291+ "$ref": "#/definitions/proto.MCPState"
3292+ },
3293+ "tool_count": {
3294+ "type": "integer"
3295+ }
3296+ }
3297+ },
3298+ "proto.MCPGetPromptRequest": {
3299+ "type": "object",
3300+ "properties": {
3301+ "args": {
3302+ "type": "object",
3303+ "additionalProperties": {
3304+ "type": "string"
3305+ }
3306+ },
3307+ "client_id": {
3308+ "type": "string"
3309+ },
3310+ "prompt_id": {
3311+ "type": "string"
3312+ }
3313+ }
3314+ },
3315+ "proto.MCPGetPromptResponse": {
3316+ "type": "object",
3317+ "properties": {
3318+ "prompt": {
3319+ "type": "string"
3320+ }
3321+ }
3322+ },
3323+ "proto.MCPNameRequest": {
3324+ "type": "object",
3325+ "properties": {
3326+ "name": {
3327+ "type": "string"
3328+ }
3329+ }
3330+ },
3331+ "proto.MCPReadResourceRequest": {
3332+ "type": "object",
3333+ "properties": {
3334+ "name": {
3335+ "type": "string"
3336+ },
3337+ "uri": {
3338+ "type": "string"
3339+ }
3340+ }
3341+ },
3342+ "proto.MCPState": {
3343+ "type": "integer",
3344+ "enum": [
3345+ 0,
3346+ 1,
3347+ 2,
3348+ 3
3349+ ],
3350+ "x-enum-varnames": [
3351+ "MCPStateDisabled",
3352+ "MCPStateStarting",
3353+ "MCPStateConnected",
3354+ "MCPStateError"
3355+ ]
3356+ },
3357+ "proto.MessageRole": {
3358+ "type": "string",
3359+ "enum": [
3360+ "assistant",
3361+ "user",
3362+ "system",
3363+ "tool"
3364+ ],
3365+ "x-enum-varnames": [
3366+ "Assistant",
3367+ "User",
3368+ "System",
3369+ "Tool"
3370+ ]
3371+ },
3372+ "proto.PermissionAction": {
3373+ "type": "string",
3374+ "enum": [
3375+ "allow",
3376+ "allow_session",
3377+ "deny"
3378+ ],
3379+ "x-enum-varnames": [
3380+ "PermissionAllow",
3381+ "PermissionAllowForSession",
3382+ "PermissionDeny"
3383+ ]
3384+ },
3385+ "proto.PermissionGrant": {
3386+ "type": "object",
3387+ "properties": {
3388+ "action": {
3389+ "$ref": "#/definitions/proto.PermissionAction"
3390+ },
3391+ "permission": {
3392+ "$ref": "#/definitions/proto.PermissionRequest"
3393+ }
3394+ }
3395+ },
3396+ "proto.PermissionRequest": {
3397+ "type": "object",
3398+ "properties": {
3399+ "action": {
3400+ "type": "string"
3401+ },
3402+ "description": {
3403+ "type": "string"
3404+ },
3405+ "id": {
3406+ "type": "string"
3407+ },
3408+ "params": {},
3409+ "path": {
3410+ "type": "string"
3411+ },
3412+ "session_id": {
3413+ "type": "string"
3414+ },
3415+ "tool_call_id": {
3416+ "type": "string"
3417+ },
3418+ "tool_name": {
3419+ "type": "string"
3420+ }
3421+ }
3422+ },
3423+ "proto.PermissionSkipRequest": {
3424+ "type": "object",
3425+ "properties": {
3426+ "skip": {
3427+ "type": "boolean"
3428+ }
3429+ }
3430+ },
3431+ "proto.ProjectInitPromptResponse": {
3432+ "type": "object",
3433+ "properties": {
3434+ "prompt": {
3435+ "type": "string"
3436+ }
3437+ }
3438+ },
3439+ "proto.ProjectNeedsInitResponse": {
3440+ "type": "object",
3441+ "properties": {
3442+ "needs_init": {
3443+ "type": "boolean"
3444+ }
3445+ }
3446+ },
3447+ "proto.ServerControl": {
3448+ "type": "object",
3449+ "properties": {
3450+ "command": {
3451+ "type": "string"
3452+ }
3453+ }
3454+ },
3455+ "proto.Session": {
3456+ "type": "object",
3457+ "properties": {
3458+ "completion_tokens": {
3459+ "type": "integer"
3460+ },
3461+ "cost": {
3462+ "type": "number"
3463+ },
3464+ "created_at": {
3465+ "type": "integer"
3466+ },
3467+ "id": {
3468+ "type": "string"
3469+ },
3470+ "message_count": {
3471+ "type": "integer"
3472+ },
3473+ "parent_session_id": {
3474+ "type": "string"
3475+ },
3476+ "prompt_tokens": {
3477+ "type": "integer"
3478+ },
3479+ "summary_message_id": {
3480+ "type": "string"
3481+ },
3482+ "title": {
3483+ "type": "string"
3484+ },
3485+ "updated_at": {
3486+ "type": "integer"
3487+ }
3488+ }
3489+ },
3490+ "proto.VersionInfo": {
3491+ "type": "object",
3492+ "properties": {
3493+ "commit": {
3494+ "type": "string"
3495+ },
3496+ "go_version": {
3497+ "type": "string"
3498+ },
3499+ "platform": {
3500+ "type": "string"
3501+ },
3502+ "version": {
3503+ "type": "string"
3504+ }
3505+ }
3506+ },
3507+ "proto.Workspace": {
3508+ "type": "object",
3509+ "properties": {
3510+ "config": {
3511+ "$ref": "#/definitions/github_com_charmbracelet_crush_internal_config.Config"
3512+ },
3513+ "data_dir": {
3514+ "type": "string"
3515+ },
3516+ "debug": {
3517+ "type": "boolean"
3518+ },
3519+ "env": {
3520+ "type": "array",
3521+ "items": {
3522+ "type": "string"
3523+ }
3524+ },
3525+ "id": {
3526+ "type": "string"
3527+ },
3528+ "path": {
3529+ "type": "string"
3530+ },
3531+ "version": {
3532+ "type": "string"
3533+ },
3534+ "yolo": {
3535+ "type": "boolean"
3536+ }
3537+ }
3538+ },
3539+ "time.Duration": {
3540+ "type": "integer",
3541+ "format": "int64",
3542+ "enum": [
3543+ -9223372036854775808,
3544+ 9223372036854775807,
3545+ 1,
3546+ 1000,
3547+ 1000000,
3548+ 1000000000,
3549+ 60000000000,
3550+ 3600000000000
3551+ ],
3552+ "x-enum-varnames": [
3553+ "minDuration",
3554+ "maxDuration",
3555+ "Nanosecond",
3556+ "Microsecond",
3557+ "Millisecond",
3558+ "Second",
3559+ "Minute",
3560+ "Hour"
3561+ ]
3562+ }
3563+ }
3564+}
internal/swagger/swagger.yamlnew file+2361
@@ -0,0 +1,2361 @@
1+basePath: /v1
2+definitions:
3+ catwalk.Model:
4+ properties:
5+ can_reason:
6+ type: boolean
7+ context_window:
8+ type: integer
9+ cost_per_1m_in:
10+ type: number
11+ cost_per_1m_in_cached:
12+ type: number
13+ cost_per_1m_out:
14+ type: number
15+ cost_per_1m_out_cached:
16+ type: number
17+ default_max_tokens:
18+ type: integer
19+ default_reasoning_effort:
20+ type: string
21+ id:
22+ type: string
23+ name:
24+ type: string
25+ options:
26+ $ref: '#/definitions/catwalk.ModelOptions'
27+ reasoning_levels:
28+ items:
29+ type: string
30+ type: array
31+ supports_attachments:
32+ type: boolean
33+ type: object
34+ catwalk.ModelOptions:
35+ properties:
36+ frequency_penalty:
37+ type: number
38+ presence_penalty:
39+ type: number
40+ provider_options:
41+ additionalProperties: {}
42+ type: object
43+ temperature:
44+ type: number
45+ top_k:
46+ type: integer
47+ top_p:
48+ type: number
49+ type: object
50+ config.Attribution:
51+ properties:
52+ co_authored_by:
53+ type: boolean
54+ generated_with:
55+ type: boolean
56+ trailer_style:
57+ $ref: '#/definitions/config.TrailerStyle'
58+ type: object
59+ config.Completions:
60+ properties:
61+ max_depth:
62+ type: integer
63+ max_items:
64+ type: integer
65+ type: object
66+ config.LSPConfig:
67+ properties:
68+ args:
69+ items:
70+ type: string
71+ type: array
72+ command:
73+ type: string
74+ disabled:
75+ type: boolean
76+ env:
77+ additionalProperties:
78+ type: string
79+ type: object
80+ filetypes:
81+ items:
82+ type: string
83+ type: array
84+ init_options:
85+ additionalProperties: {}
86+ type: object
87+ options:
88+ additionalProperties: {}
89+ type: object
90+ root_markers:
91+ items:
92+ type: string
93+ type: array
94+ timeout:
95+ type: integer
96+ type: object
97+ config.LSPs:
98+ additionalProperties:
99+ $ref: '#/definitions/config.LSPConfig'
100+ type: object
101+ config.MCPConfig:
102+ properties:
103+ args:
104+ items:
105+ type: string
106+ type: array
107+ command:
108+ type: string
109+ disabled:
110+ type: boolean
111+ disabled_tools:
112+ items:
113+ type: string
114+ type: array
115+ env:
116+ additionalProperties:
117+ type: string
118+ type: object
119+ headers:
120+ additionalProperties:
121+ type: string
122+ description: 'TODO: maybe make it possible to get the value from the env'
123+ type: object
124+ timeout:
125+ type: integer
126+ type:
127+ $ref: '#/definitions/config.MCPType'
128+ url:
129+ type: string
130+ type: object
131+ config.MCPType:
132+ enum:
133+ - stdio
134+ - sse
135+ - http
136+ type: string
137+ x-enum-varnames:
138+ - MCPStdio
139+ - MCPSSE
140+ - MCPHttp
141+ config.MCPs:
142+ additionalProperties:
143+ $ref: '#/definitions/config.MCPConfig'
144+ type: object
145+ config.Permissions:
146+ properties:
147+ allowed_tools:
148+ items:
149+ type: string
150+ type: array
151+ type: object
152+ config.Scope:
153+ enum:
154+ - 0
155+ - 1
156+ type: integer
157+ x-enum-varnames:
158+ - ScopeGlobal
159+ - ScopeWorkspace
160+ config.SelectedModel:
161+ properties:
162+ frequency_penalty:
163+ type: number
164+ max_tokens:
165+ description: Overrides the default model configuration.
166+ type: integer
167+ model:
168+ description: |-
169+ The model id as used by the provider API.
170+ Required.
171+ type: string
172+ presence_penalty:
173+ type: number
174+ provider:
175+ description: |-
176+ The model provider, same as the key/id used in the providers config.
177+ Required.
178+ type: string
179+ provider_options:
180+ additionalProperties: {}
181+ description: Override provider specific options.
182+ type: object
183+ reasoning_effort:
184+ description: Only used by models that use the openai provider and need this
185+ set.
186+ type: string
187+ temperature:
188+ type: number
189+ think:
190+ description: Used by anthropic models that can reason to indicate if the model
191+ should think.
192+ type: boolean
193+ top_k:
194+ type: integer
195+ top_p:
196+ type: number
197+ type: object
198+ config.SelectedModelType:
199+ enum:
200+ - large
201+ - small
202+ type: string
203+ x-enum-varnames:
204+ - SelectedModelTypeLarge
205+ - SelectedModelTypeSmall
206+ config.TUIOptions:
207+ properties:
208+ compact_mode:
209+ type: boolean
210+ completions:
211+ $ref: '#/definitions/config.Completions'
212+ diff_mode:
213+ type: string
214+ transparent:
215+ type: boolean
216+ type: object
217+ config.ToolGrep:
218+ properties:
219+ timeout:
220+ $ref: '#/definitions/time.Duration'
221+ type: object
222+ config.ToolLs:
223+ properties:
224+ max_depth:
225+ type: integer
226+ max_items:
227+ type: integer
228+ type: object
229+ config.Tools:
230+ properties:
231+ grep:
232+ $ref: '#/definitions/config.ToolGrep'
233+ ls:
234+ $ref: '#/definitions/config.ToolLs'
235+ type: object
236+ config.TrailerStyle:
237+ enum:
238+ - none
239+ - co-authored-by
240+ - assisted-by
241+ type: string
242+ x-enum-varnames:
243+ - TrailerStyleNone
244+ - TrailerStyleCoAuthoredBy
245+ - TrailerStyleAssistedBy
246+ csync.Map-string-config_ProviderConfig:
247+ type: object
248+ github_com_charmbracelet_crush_internal_config.Config:
249+ properties:
250+ $schema:
251+ type: string
252+ lsp:
253+ $ref: '#/definitions/config.LSPs'
254+ mcp:
255+ $ref: '#/definitions/config.MCPs'
256+ models:
257+ additionalProperties:
258+ $ref: '#/definitions/config.SelectedModel'
259+ description: We currently only support large/small as values here.
260+ type: object
261+ options:
262+ $ref: '#/definitions/github_com_charmbracelet_crush_internal_config.Options'
263+ permissions:
264+ $ref: '#/definitions/config.Permissions'
265+ providers:
266+ allOf:
267+ - $ref: '#/definitions/csync.Map-string-config_ProviderConfig'
268+ description: The providers that are configured
269+ recent_models:
270+ additionalProperties:
271+ items:
272+ $ref: '#/definitions/config.SelectedModel'
273+ type: array
274+ description: Recently used models stored in the data directory config.
275+ type: object
276+ tools:
277+ $ref: '#/definitions/config.Tools'
278+ type: object
279+ github_com_charmbracelet_crush_internal_config.Options:
280+ properties:
281+ attribution:
282+ $ref: '#/definitions/config.Attribution'
283+ auto_lsp:
284+ type: boolean
285+ context_paths:
286+ items:
287+ type: string
288+ type: array
289+ data_directory:
290+ description: Relative to the cwd
291+ type: string
292+ debug:
293+ type: boolean
294+ debug_lsp:
295+ type: boolean
296+ disable_auto_summarize:
297+ type: boolean
298+ disable_default_providers:
299+ type: boolean
300+ disable_metrics:
301+ type: boolean
302+ disable_notifications:
303+ type: boolean
304+ disable_provider_auto_update:
305+ type: boolean
306+ disabled_tools:
307+ items:
308+ type: string
309+ type: array
310+ initialize_as:
311+ type: string
312+ progress:
313+ type: boolean
314+ skills_paths:
315+ items:
316+ type: string
317+ type: array
318+ tui:
319+ $ref: '#/definitions/config.TUIOptions'
320+ type: object
321+ github_com_charmbracelet_crush_internal_proto.Message:
322+ properties:
323+ created_at:
324+ type: integer
325+ id:
326+ type: string
327+ model:
328+ type: string
329+ parts:
330+ items: {}
331+ type: array
332+ provider:
333+ type: string
334+ role:
335+ $ref: '#/definitions/proto.MessageRole'
336+ session_id:
337+ type: string
338+ updated_at:
339+ type: integer
340+ type: object
341+ lsp.ServerState:
342+ enum:
343+ - 0
344+ - 1
345+ - 2
346+ - 3
347+ - 4
348+ - 5
349+ type: integer
350+ x-enum-varnames:
351+ - StateUnstarted
352+ - StateStarting
353+ - StateReady
354+ - StateError
355+ - StateStopped
356+ - StateDisabled
357+ proto.AgentInfo:
358+ properties:
359+ is_busy:
360+ type: boolean
361+ is_ready:
362+ type: boolean
363+ model:
364+ $ref: '#/definitions/catwalk.Model'
365+ model_cfg:
366+ $ref: '#/definitions/config.SelectedModel'
367+ type: object
368+ proto.AgentMessage:
369+ properties:
370+ attachments:
371+ items:
372+ $ref: '#/definitions/proto.Attachment'
373+ type: array
374+ prompt:
375+ type: string
376+ session_id:
377+ type: string
378+ type: object
379+ proto.AgentSession:
380+ properties:
381+ completion_tokens:
382+ type: integer
383+ cost:
384+ type: number
385+ created_at:
386+ type: integer
387+ id:
388+ type: string
389+ is_busy:
390+ type: boolean
391+ message_count:
392+ type: integer
393+ parent_session_id:
394+ type: string
395+ prompt_tokens:
396+ type: integer
397+ summary_message_id:
398+ type: string
399+ title:
400+ type: string
401+ updated_at:
402+ type: integer
403+ type: object
404+ proto.Attachment:
405+ properties:
406+ content:
407+ items:
408+ type: integer
409+ type: array
410+ file_name:
411+ type: string
412+ file_path:
413+ type: string
414+ mime_type:
415+ type: string
416+ type: object
417+ proto.ConfigCompactRequest:
418+ properties:
419+ enabled:
420+ type: boolean
421+ scope:
422+ $ref: '#/definitions/config.Scope'
423+ type: object
424+ proto.ConfigModelRequest:
425+ properties:
426+ model:
427+ $ref: '#/definitions/config.SelectedModel'
428+ model_type:
429+ $ref: '#/definitions/config.SelectedModelType'
430+ scope:
431+ $ref: '#/definitions/config.Scope'
432+ type: object
433+ proto.ConfigProviderKeyRequest:
434+ properties:
435+ api_key: {}
436+ provider_id:
437+ type: string
438+ scope:
439+ $ref: '#/definitions/config.Scope'
440+ type: object
441+ proto.ConfigRefreshOAuthRequest:
442+ properties:
443+ provider_id:
444+ type: string
445+ scope:
446+ $ref: '#/definitions/config.Scope'
447+ type: object
448+ proto.ConfigRemoveRequest:
449+ properties:
450+ key:
451+ type: string
452+ scope:
453+ $ref: '#/definitions/config.Scope'
454+ type: object
455+ proto.ConfigSetRequest:
456+ properties:
457+ key:
458+ type: string
459+ scope:
460+ $ref: '#/definitions/config.Scope'
461+ value: {}
462+ type: object
463+ proto.Error:
464+ properties:
465+ message:
466+ type: string
467+ type: object
468+ proto.File:
469+ properties:
470+ content:
471+ type: string
472+ created_at:
473+ type: integer
474+ id:
475+ type: string
476+ path:
477+ type: string
478+ session_id:
479+ type: string
480+ updated_at:
481+ type: integer
482+ version:
483+ type: integer
484+ type: object
485+ proto.FileTrackerReadRequest:
486+ properties:
487+ path:
488+ type: string
489+ session_id:
490+ type: string
491+ type: object
492+ proto.ImportCopilotResponse:
493+ properties:
494+ success:
495+ type: boolean
496+ token: {}
497+ type: object
498+ proto.LSPClientInfo:
499+ properties:
500+ connected_at:
501+ type: string
502+ diagnostic_count:
503+ type: integer
504+ error: {}
505+ name:
506+ type: string
507+ state:
508+ $ref: '#/definitions/lsp.ServerState'
509+ type: object
510+ proto.LSPStartRequest:
511+ properties:
512+ path:
513+ type: string
514+ type: object
515+ proto.MCPClientInfo:
516+ properties:
517+ connected_at:
518+ type: string
519+ error: {}
520+ name:
521+ type: string
522+ prompt_count:
523+ type: integer
524+ resource_count:
525+ type: integer
526+ state:
527+ $ref: '#/definitions/proto.MCPState'
528+ tool_count:
529+ type: integer
530+ type: object
531+ proto.MCPGetPromptRequest:
532+ properties:
533+ args:
534+ additionalProperties:
535+ type: string
536+ type: object
537+ client_id:
538+ type: string
539+ prompt_id:
540+ type: string
541+ type: object
542+ proto.MCPGetPromptResponse:
543+ properties:
544+ prompt:
545+ type: string
546+ type: object
547+ proto.MCPNameRequest:
548+ properties:
549+ name:
550+ type: string
551+ type: object
552+ proto.MCPReadResourceRequest:
553+ properties:
554+ name:
555+ type: string
556+ uri:
557+ type: string
558+ type: object
559+ proto.MCPState:
560+ enum:
561+ - 0
562+ - 1
563+ - 2
564+ - 3
565+ type: integer
566+ x-enum-varnames:
567+ - MCPStateDisabled
568+ - MCPStateStarting
569+ - MCPStateConnected
570+ - MCPStateError
571+ proto.MessageRole:
572+ enum:
573+ - assistant
574+ - user
575+ - system
576+ - tool
577+ type: string
578+ x-enum-varnames:
579+ - Assistant
580+ - User
581+ - System
582+ - Tool
583+ proto.PermissionAction:
584+ enum:
585+ - allow
586+ - allow_session
587+ - deny
588+ type: string
589+ x-enum-varnames:
590+ - PermissionAllow
591+ - PermissionAllowForSession
592+ - PermissionDeny
593+ proto.PermissionGrant:
594+ properties:
595+ action:
596+ $ref: '#/definitions/proto.PermissionAction'
597+ permission:
598+ $ref: '#/definitions/proto.PermissionRequest'
599+ type: object
600+ proto.PermissionRequest:
601+ properties:
602+ action:
603+ type: string
604+ description:
605+ type: string
606+ id:
607+ type: string
608+ params: {}
609+ path:
610+ type: string
611+ session_id:
612+ type: string
613+ tool_call_id:
614+ type: string
615+ tool_name:
616+ type: string
617+ type: object
618+ proto.PermissionSkipRequest:
619+ properties:
620+ skip:
621+ type: boolean
622+ type: object
623+ proto.ProjectInitPromptResponse:
624+ properties:
625+ prompt:
626+ type: string
627+ type: object
628+ proto.ProjectNeedsInitResponse:
629+ properties:
630+ needs_init:
631+ type: boolean
632+ type: object
633+ proto.ServerControl:
634+ properties:
635+ command:
636+ type: string
637+ type: object
638+ proto.Session:
639+ properties:
640+ completion_tokens:
641+ type: integer
642+ cost:
643+ type: number
644+ created_at:
645+ type: integer
646+ id:
647+ type: string
648+ message_count:
649+ type: integer
650+ parent_session_id:
651+ type: string
652+ prompt_tokens:
653+ type: integer
654+ summary_message_id:
655+ type: string
656+ title:
657+ type: string
658+ updated_at:
659+ type: integer
660+ type: object
661+ proto.VersionInfo:
662+ properties:
663+ commit:
664+ type: string
665+ go_version:
666+ type: string
667+ platform:
668+ type: string
669+ version:
670+ type: string
671+ type: object
672+ proto.Workspace:
673+ properties:
674+ config:
675+ $ref: '#/definitions/github_com_charmbracelet_crush_internal_config.Config'
676+ data_dir:
677+ type: string
678+ debug:
679+ type: boolean
680+ env:
681+ items:
682+ type: string
683+ type: array
684+ id:
685+ type: string
686+ path:
687+ type: string
688+ version:
689+ type: string
690+ yolo:
691+ type: boolean
692+ type: object
693+ time.Duration:
694+ enum:
695+ - -9223372036854775808
696+ - 9223372036854775807
697+ - 1
698+ - 1000
699+ - 1000000
700+ - 1000000000
701+ - 60000000000
702+ - 3600000000000
703+ format: int64
704+ type: integer
705+ x-enum-varnames:
706+ - minDuration
707+ - maxDuration
708+ - Nanosecond
709+ - Microsecond
710+ - Millisecond
711+ - Second
712+ - Minute
713+ - Hour
714+info:
715+ contact:
716+ name: Charm
717+ url: https://charm.sh
718+ description: Crush is a terminal-based AI coding assistant. This API is served over
719+ a Unix socket (or Windows named pipe) and provides programmatic access to workspaces,
720+ sessions, agents, LSP, MCP, and more.
721+ license:
722+ name: MIT
723+ url: https://github.com/charmbracelet/crush/blob/main/LICENSE
724+ title: Crush API
725+ version: "1.0"
726+paths:
727+ /config:
728+ get:
729+ produces:
730+ - application/json
731+ responses:
732+ "200":
733+ description: OK
734+ schema:
735+ type: object
736+ summary: Get server config
737+ tags:
738+ - system
739+ /control:
740+ post:
741+ consumes:
742+ - application/json
743+ parameters:
744+ - description: Control command (e.g. shutdown)
745+ in: body
746+ name: request
747+ required: true
748+ schema:
749+ $ref: '#/definitions/proto.ServerControl'
750+ responses:
751+ "200":
752+ description: OK
753+ "400":
754+ description: Bad Request
755+ schema:
756+ $ref: '#/definitions/proto.Error'
757+ summary: Send server control command
758+ tags:
759+ - system
760+ /health:
761+ get:
762+ responses:
763+ "200":
764+ description: OK
765+ summary: Health check
766+ tags:
767+ - system
768+ /version:
769+ get:
770+ produces:
771+ - application/json
772+ responses:
773+ "200":
774+ description: OK
775+ schema:
776+ $ref: '#/definitions/proto.VersionInfo'
777+ summary: Get server version
778+ tags:
779+ - system
780+ /workspaces:
781+ get:
782+ produces:
783+ - application/json
784+ responses:
785+ "200":
786+ description: OK
787+ schema:
788+ items:
789+ $ref: '#/definitions/proto.Workspace'
790+ type: array
791+ summary: List workspaces
792+ tags:
793+ - workspaces
794+ post:
795+ consumes:
796+ - application/json
797+ parameters:
798+ - description: Workspace creation params
799+ in: body
800+ name: request
801+ required: true
802+ schema:
803+ $ref: '#/definitions/proto.Workspace'
804+ produces:
805+ - application/json
806+ responses:
807+ "200":
808+ description: OK
809+ schema:
810+ $ref: '#/definitions/proto.Workspace'
811+ "400":
812+ description: Bad Request
813+ schema:
814+ $ref: '#/definitions/proto.Error'
815+ "500":
816+ description: Internal Server Error
817+ schema:
818+ $ref: '#/definitions/proto.Error'
819+ summary: Create workspace
820+ tags:
821+ - workspaces
822+ /workspaces/{id}:
823+ delete:
824+ parameters:
825+ - description: Workspace ID
826+ in: path
827+ name: id
828+ required: true
829+ type: string
830+ responses:
831+ "200":
832+ description: OK
833+ "404":
834+ description: Not Found
835+ schema:
836+ $ref: '#/definitions/proto.Error'
837+ summary: Delete workspace
838+ tags:
839+ - workspaces
840+ get:
841+ parameters:
842+ - description: Workspace ID
843+ in: path
844+ name: id
845+ required: true
846+ type: string
847+ produces:
848+ - application/json
849+ responses:
850+ "200":
851+ description: OK
852+ schema:
853+ $ref: '#/definitions/proto.Workspace'
854+ "404":
855+ description: Not Found
856+ schema:
857+ $ref: '#/definitions/proto.Error'
858+ "500":
859+ description: Internal Server Error
860+ schema:
861+ $ref: '#/definitions/proto.Error'
862+ summary: Get workspace
863+ tags:
864+ - workspaces
865+ /workspaces/{id}/agent:
866+ get:
867+ parameters:
868+ - description: Workspace ID
869+ in: path
870+ name: id
871+ required: true
872+ type: string
873+ produces:
874+ - application/json
875+ responses:
876+ "200":
877+ description: OK
878+ schema:
879+ $ref: '#/definitions/proto.AgentInfo'
880+ "404":
881+ description: Not Found
882+ schema:
883+ $ref: '#/definitions/proto.Error'
884+ "500":
885+ description: Internal Server Error
886+ schema:
887+ $ref: '#/definitions/proto.Error'
888+ summary: Get agent info
889+ tags:
890+ - agent
891+ post:
892+ consumes:
893+ - application/json
894+ parameters:
895+ - description: Workspace ID
896+ in: path
897+ name: id
898+ required: true
899+ type: string
900+ - description: Agent message
901+ in: body
902+ name: request
903+ required: true
904+ schema:
905+ $ref: '#/definitions/proto.AgentMessage'
906+ responses:
907+ "200":
908+ description: OK
909+ "400":
910+ description: Bad Request
911+ schema:
912+ $ref: '#/definitions/proto.Error'
913+ "404":
914+ description: Not Found
915+ schema:
916+ $ref: '#/definitions/proto.Error'
917+ "500":
918+ description: Internal Server Error
919+ schema:
920+ $ref: '#/definitions/proto.Error'
921+ summary: Send message to agent
922+ tags:
923+ - agent
924+ /workspaces/{id}/agent/default-small-model:
925+ get:
926+ parameters:
927+ - description: Workspace ID
928+ in: path
929+ name: id
930+ required: true
931+ type: string
932+ - description: Provider ID
933+ in: query
934+ name: provider_id
935+ type: string
936+ produces:
937+ - application/json
938+ responses:
939+ "200":
940+ description: OK
941+ schema:
942+ type: object
943+ "404":
944+ description: Not Found
945+ schema:
946+ $ref: '#/definitions/proto.Error'
947+ "500":
948+ description: Internal Server Error
949+ schema:
950+ $ref: '#/definitions/proto.Error'
951+ summary: Get default small model
952+ tags:
953+ - agent
954+ /workspaces/{id}/agent/init:
955+ post:
956+ parameters:
957+ - description: Workspace ID
958+ in: path
959+ name: id
960+ required: true
961+ type: string
962+ responses:
963+ "200":
964+ description: OK
965+ "404":
966+ description: Not Found
967+ schema:
968+ $ref: '#/definitions/proto.Error'
969+ "500":
970+ description: Internal Server Error
971+ schema:
972+ $ref: '#/definitions/proto.Error'
973+ summary: Initialize agent
974+ tags:
975+ - agent
976+ /workspaces/{id}/agent/sessions/{sid}:
977+ get:
978+ parameters:
979+ - description: Workspace ID
980+ in: path
981+ name: id
982+ required: true
983+ type: string
984+ - description: Session ID
985+ in: path
986+ name: sid
987+ required: true
988+ type: string
989+ produces:
990+ - application/json
991+ responses:
992+ "200":
993+ description: OK
994+ schema:
995+ $ref: '#/definitions/proto.AgentSession'
996+ "404":
997+ description: Not Found
998+ schema:
999+ $ref: '#/definitions/proto.Error'
1000+ "500":
1001+ description: Internal Server Error
1002+ schema:
1003+ $ref: '#/definitions/proto.Error'
1004+ summary: Get agent session
1005+ tags:
1006+ - agent
1007+ /workspaces/{id}/agent/sessions/{sid}/cancel:
1008+ post:
1009+ parameters:
1010+ - description: Workspace ID
1011+ in: path
1012+ name: id
1013+ required: true
1014+ type: string
1015+ - description: Session ID
1016+ in: path
1017+ name: sid
1018+ required: true
1019+ type: string
1020+ responses:
1021+ "200":
1022+ description: OK
1023+ "404":
1024+ description: Not Found
1025+ schema:
1026+ $ref: '#/definitions/proto.Error'
1027+ "500":
1028+ description: Internal Server Error
1029+ schema:
1030+ $ref: '#/definitions/proto.Error'
1031+ summary: Cancel agent session
1032+ tags:
1033+ - agent
1034+ /workspaces/{id}/agent/sessions/{sid}/prompts/clear:
1035+ post:
1036+ parameters:
1037+ - description: Workspace ID
1038+ in: path
1039+ name: id
1040+ required: true
1041+ type: string
1042+ - description: Session ID
1043+ in: path
1044+ name: sid
1045+ required: true
1046+ type: string
1047+ responses:
1048+ "200":
1049+ description: OK
1050+ "404":
1051+ description: Not Found
1052+ schema:
1053+ $ref: '#/definitions/proto.Error'
1054+ "500":
1055+ description: Internal Server Error
1056+ schema:
1057+ $ref: '#/definitions/proto.Error'
1058+ summary: Clear prompt queue
1059+ tags:
1060+ - agent
1061+ /workspaces/{id}/agent/sessions/{sid}/prompts/list:
1062+ get:
1063+ parameters:
1064+ - description: Workspace ID
1065+ in: path
1066+ name: id
1067+ required: true
1068+ type: string
1069+ - description: Session ID
1070+ in: path
1071+ name: sid
1072+ required: true
1073+ type: string
1074+ produces:
1075+ - application/json
1076+ responses:
1077+ "200":
1078+ description: OK
1079+ schema:
1080+ items:
1081+ type: string
1082+ type: array
1083+ "404":
1084+ description: Not Found
1085+ schema:
1086+ $ref: '#/definitions/proto.Error'
1087+ "500":
1088+ description: Internal Server Error
1089+ schema:
1090+ $ref: '#/definitions/proto.Error'
1091+ summary: List queued prompts
1092+ tags:
1093+ - agent
1094+ /workspaces/{id}/agent/sessions/{sid}/prompts/queued:
1095+ get:
1096+ parameters:
1097+ - description: Workspace ID
1098+ in: path
1099+ name: id
1100+ required: true
1101+ type: string
1102+ - description: Session ID
1103+ in: path
1104+ name: sid
1105+ required: true
1106+ type: string
1107+ produces:
1108+ - application/json
1109+ responses:
1110+ "200":
1111+ description: OK
1112+ schema:
1113+ type: object
1114+ "404":
1115+ description: Not Found
1116+ schema:
1117+ $ref: '#/definitions/proto.Error'
1118+ "500":
1119+ description: Internal Server Error
1120+ schema:
1121+ $ref: '#/definitions/proto.Error'
1122+ summary: Get queued prompt status
1123+ tags:
1124+ - agent
1125+ /workspaces/{id}/agent/sessions/{sid}/summarize:
1126+ post:
1127+ parameters:
1128+ - description: Workspace ID
1129+ in: path
1130+ name: id
1131+ required: true
1132+ type: string
1133+ - description: Session ID
1134+ in: path
1135+ name: sid
1136+ required: true
1137+ type: string
1138+ responses:
1139+ "200":
1140+ description: OK
1141+ "404":
1142+ description: Not Found
1143+ schema:
1144+ $ref: '#/definitions/proto.Error'
1145+ "500":
1146+ description: Internal Server Error
1147+ schema:
1148+ $ref: '#/definitions/proto.Error'
1149+ summary: Summarize session
1150+ tags:
1151+ - agent
1152+ /workspaces/{id}/agent/update:
1153+ post:
1154+ parameters:
1155+ - description: Workspace ID
1156+ in: path
1157+ name: id
1158+ required: true
1159+ type: string
1160+ responses:
1161+ "200":
1162+ description: OK
1163+ "404":
1164+ description: Not Found
1165+ schema:
1166+ $ref: '#/definitions/proto.Error'
1167+ "500":
1168+ description: Internal Server Error
1169+ schema:
1170+ $ref: '#/definitions/proto.Error'
1171+ summary: Update agent
1172+ tags:
1173+ - agent
1174+ /workspaces/{id}/config:
1175+ get:
1176+ parameters:
1177+ - description: Workspace ID
1178+ in: path
1179+ name: id
1180+ required: true
1181+ type: string
1182+ produces:
1183+ - application/json
1184+ responses:
1185+ "200":
1186+ description: OK
1187+ schema:
1188+ type: object
1189+ "404":
1190+ description: Not Found
1191+ schema:
1192+ $ref: '#/definitions/proto.Error'
1193+ "500":
1194+ description: Internal Server Error
1195+ schema:
1196+ $ref: '#/definitions/proto.Error'
1197+ summary: Get workspace config
1198+ tags:
1199+ - workspaces
1200+ /workspaces/{id}/config/compact:
1201+ post:
1202+ consumes:
1203+ - application/json
1204+ parameters:
1205+ - description: Workspace ID
1206+ in: path
1207+ name: id
1208+ required: true
1209+ type: string
1210+ - description: Config compact request
1211+ in: body
1212+ name: request
1213+ required: true
1214+ schema:
1215+ $ref: '#/definitions/proto.ConfigCompactRequest'
1216+ responses:
1217+ "200":
1218+ description: OK
1219+ "400":
1220+ description: Bad Request
1221+ schema:
1222+ $ref: '#/definitions/proto.Error'
1223+ "404":
1224+ description: Not Found
1225+ schema:
1226+ $ref: '#/definitions/proto.Error'
1227+ "500":
1228+ description: Internal Server Error
1229+ schema:
1230+ $ref: '#/definitions/proto.Error'
1231+ summary: Set compact mode
1232+ tags:
1233+ - config
1234+ /workspaces/{id}/config/import-copilot:
1235+ post:
1236+ parameters:
1237+ - description: Workspace ID
1238+ in: path
1239+ name: id
1240+ required: true
1241+ type: string
1242+ produces:
1243+ - application/json
1244+ responses:
1245+ "200":
1246+ description: OK
1247+ schema:
1248+ $ref: '#/definitions/proto.ImportCopilotResponse'
1249+ "404":
1250+ description: Not Found
1251+ schema:
1252+ $ref: '#/definitions/proto.Error'
1253+ "500":
1254+ description: Internal Server Error
1255+ schema:
1256+ $ref: '#/definitions/proto.Error'
1257+ summary: Import Copilot credentials
1258+ tags:
1259+ - config
1260+ /workspaces/{id}/config/model:
1261+ post:
1262+ consumes:
1263+ - application/json
1264+ parameters:
1265+ - description: Workspace ID
1266+ in: path
1267+ name: id
1268+ required: true
1269+ type: string
1270+ - description: Config model request
1271+ in: body
1272+ name: request
1273+ required: true
1274+ schema:
1275+ $ref: '#/definitions/proto.ConfigModelRequest'
1276+ responses:
1277+ "200":
1278+ description: OK
1279+ "400":
1280+ description: Bad Request
1281+ schema:
1282+ $ref: '#/definitions/proto.Error'
1283+ "404":
1284+ description: Not Found
1285+ schema:
1286+ $ref: '#/definitions/proto.Error'
1287+ "500":
1288+ description: Internal Server Error
1289+ schema:
1290+ $ref: '#/definitions/proto.Error'
1291+ summary: Set the preferred model
1292+ tags:
1293+ - config
1294+ /workspaces/{id}/config/provider-key:
1295+ post:
1296+ consumes:
1297+ - application/json
1298+ parameters:
1299+ - description: Workspace ID
1300+ in: path
1301+ name: id
1302+ required: true
1303+ type: string
1304+ - description: Config provider key request
1305+ in: body
1306+ name: request
1307+ required: true
1308+ schema:
1309+ $ref: '#/definitions/proto.ConfigProviderKeyRequest'
1310+ responses:
1311+ "200":
1312+ description: OK
1313+ "400":
1314+ description: Bad Request
1315+ schema:
1316+ $ref: '#/definitions/proto.Error'
1317+ "404":
1318+ description: Not Found
1319+ schema:
1320+ $ref: '#/definitions/proto.Error'
1321+ "500":
1322+ description: Internal Server Error
1323+ schema:
1324+ $ref: '#/definitions/proto.Error'
1325+ summary: Set provider API key
1326+ tags:
1327+ - config
1328+ /workspaces/{id}/config/refresh-oauth:
1329+ post:
1330+ consumes:
1331+ - application/json
1332+ parameters:
1333+ - description: Workspace ID
1334+ in: path
1335+ name: id
1336+ required: true
1337+ type: string
1338+ - description: Refresh OAuth request
1339+ in: body
1340+ name: request
1341+ required: true
1342+ schema:
1343+ $ref: '#/definitions/proto.ConfigRefreshOAuthRequest'
1344+ responses:
1345+ "200":
1346+ description: OK
1347+ "400":
1348+ description: Bad Request
1349+ schema:
1350+ $ref: '#/definitions/proto.Error'
1351+ "404":
1352+ description: Not Found
1353+ schema:
1354+ $ref: '#/definitions/proto.Error'
1355+ "500":
1356+ description: Internal Server Error
1357+ schema:
1358+ $ref: '#/definitions/proto.Error'
1359+ summary: Refresh OAuth token
1360+ tags:
1361+ - config
1362+ /workspaces/{id}/config/remove:
1363+ post:
1364+ consumes:
1365+ - application/json
1366+ parameters:
1367+ - description: Workspace ID
1368+ in: path
1369+ name: id
1370+ required: true
1371+ type: string
1372+ - description: Config remove request
1373+ in: body
1374+ name: request
1375+ required: true
1376+ schema:
1377+ $ref: '#/definitions/proto.ConfigRemoveRequest'
1378+ responses:
1379+ "200":
1380+ description: OK
1381+ "400":
1382+ description: Bad Request
1383+ schema:
1384+ $ref: '#/definitions/proto.Error'
1385+ "404":
1386+ description: Not Found
1387+ schema:
1388+ $ref: '#/definitions/proto.Error'
1389+ "500":
1390+ description: Internal Server Error
1391+ schema:
1392+ $ref: '#/definitions/proto.Error'
1393+ summary: Remove a config field
1394+ tags:
1395+ - config
1396+ /workspaces/{id}/config/set:
1397+ post:
1398+ consumes:
1399+ - application/json
1400+ parameters:
1401+ - description: Workspace ID
1402+ in: path
1403+ name: id
1404+ required: true
1405+ type: string
1406+ - description: Config set request
1407+ in: body
1408+ name: request
1409+ required: true
1410+ schema:
1411+ $ref: '#/definitions/proto.ConfigSetRequest'
1412+ responses:
1413+ "200":
1414+ description: OK
1415+ "400":
1416+ description: Bad Request
1417+ schema:
1418+ $ref: '#/definitions/proto.Error'
1419+ "404":
1420+ description: Not Found
1421+ schema:
1422+ $ref: '#/definitions/proto.Error'
1423+ "500":
1424+ description: Internal Server Error
1425+ schema:
1426+ $ref: '#/definitions/proto.Error'
1427+ summary: Set a config field
1428+ tags:
1429+ - config
1430+ /workspaces/{id}/events:
1431+ get:
1432+ parameters:
1433+ - description: Workspace ID
1434+ in: path
1435+ name: id
1436+ required: true
1437+ type: string
1438+ produces:
1439+ - text/event-stream
1440+ responses:
1441+ "200":
1442+ description: OK
1443+ "404":
1444+ description: Not Found
1445+ schema:
1446+ $ref: '#/definitions/proto.Error'
1447+ "500":
1448+ description: Internal Server Error
1449+ schema:
1450+ $ref: '#/definitions/proto.Error'
1451+ summary: Stream workspace events (SSE)
1452+ tags:
1453+ - workspaces
1454+ /workspaces/{id}/filetracker/lastread:
1455+ get:
1456+ parameters:
1457+ - description: Workspace ID
1458+ in: path
1459+ name: id
1460+ required: true
1461+ type: string
1462+ - description: Session ID
1463+ in: query
1464+ name: session_id
1465+ type: string
1466+ - description: File path
1467+ in: query
1468+ name: path
1469+ required: true
1470+ type: string
1471+ produces:
1472+ - application/json
1473+ responses:
1474+ "200":
1475+ description: OK
1476+ schema:
1477+ type: object
1478+ "404":
1479+ description: Not Found
1480+ schema:
1481+ $ref: '#/definitions/proto.Error'
1482+ "500":
1483+ description: Internal Server Error
1484+ schema:
1485+ $ref: '#/definitions/proto.Error'
1486+ summary: Get last read time for file
1487+ tags:
1488+ - filetracker
1489+ /workspaces/{id}/filetracker/read:
1490+ post:
1491+ consumes:
1492+ - application/json
1493+ parameters:
1494+ - description: Workspace ID
1495+ in: path
1496+ name: id
1497+ required: true
1498+ type: string
1499+ - description: File tracker read request
1500+ in: body
1501+ name: request
1502+ required: true
1503+ schema:
1504+ $ref: '#/definitions/proto.FileTrackerReadRequest'
1505+ responses:
1506+ "200":
1507+ description: OK
1508+ "400":
1509+ description: Bad Request
1510+ schema:
1511+ $ref: '#/definitions/proto.Error'
1512+ "404":
1513+ description: Not Found
1514+ schema:
1515+ $ref: '#/definitions/proto.Error'
1516+ "500":
1517+ description: Internal Server Error
1518+ schema:
1519+ $ref: '#/definitions/proto.Error'
1520+ summary: Record file read
1521+ tags:
1522+ - filetracker
1523+ /workspaces/{id}/lsps:
1524+ get:
1525+ parameters:
1526+ - description: Workspace ID
1527+ in: path
1528+ name: id
1529+ required: true
1530+ type: string
1531+ produces:
1532+ - application/json
1533+ responses:
1534+ "200":
1535+ description: OK
1536+ schema:
1537+ additionalProperties:
1538+ $ref: '#/definitions/proto.LSPClientInfo'
1539+ type: object
1540+ "404":
1541+ description: Not Found
1542+ schema:
1543+ $ref: '#/definitions/proto.Error'
1544+ "500":
1545+ description: Internal Server Error
1546+ schema:
1547+ $ref: '#/definitions/proto.Error'
1548+ summary: List LSP clients
1549+ tags:
1550+ - lsp
1551+ /workspaces/{id}/lsps/{lsp}/diagnostics:
1552+ get:
1553+ parameters:
1554+ - description: Workspace ID
1555+ in: path
1556+ name: id
1557+ required: true
1558+ type: string
1559+ - description: LSP client name
1560+ in: path
1561+ name: lsp
1562+ required: true
1563+ type: string
1564+ produces:
1565+ - application/json
1566+ responses:
1567+ "200":
1568+ description: OK
1569+ schema:
1570+ type: object
1571+ "404":
1572+ description: Not Found
1573+ schema:
1574+ $ref: '#/definitions/proto.Error'
1575+ "500":
1576+ description: Internal Server Error
1577+ schema:
1578+ $ref: '#/definitions/proto.Error'
1579+ summary: Get LSP diagnostics
1580+ tags:
1581+ - lsp
1582+ /workspaces/{id}/lsps/start:
1583+ post:
1584+ consumes:
1585+ - application/json
1586+ parameters:
1587+ - description: Workspace ID
1588+ in: path
1589+ name: id
1590+ required: true
1591+ type: string
1592+ - description: LSP start request
1593+ in: body
1594+ name: request
1595+ required: true
1596+ schema:
1597+ $ref: '#/definitions/proto.LSPStartRequest'
1598+ responses:
1599+ "200":
1600+ description: OK
1601+ "400":
1602+ description: Bad Request
1603+ schema:
1604+ $ref: '#/definitions/proto.Error'
1605+ "404":
1606+ description: Not Found
1607+ schema:
1608+ $ref: '#/definitions/proto.Error'
1609+ "500":
1610+ description: Internal Server Error
1611+ schema:
1612+ $ref: '#/definitions/proto.Error'
1613+ summary: Start LSP server
1614+ tags:
1615+ - lsp
1616+ /workspaces/{id}/lsps/stop:
1617+ post:
1618+ parameters:
1619+ - description: Workspace ID
1620+ in: path
1621+ name: id
1622+ required: true
1623+ type: string
1624+ responses:
1625+ "200":
1626+ description: OK
1627+ "404":
1628+ description: Not Found
1629+ schema:
1630+ $ref: '#/definitions/proto.Error'
1631+ "500":
1632+ description: Internal Server Error
1633+ schema:
1634+ $ref: '#/definitions/proto.Error'
1635+ summary: Stop all LSP servers
1636+ tags:
1637+ - lsp
1638+ /workspaces/{id}/mcp/get-prompt:
1639+ post:
1640+ consumes:
1641+ - application/json
1642+ parameters:
1643+ - description: Workspace ID
1644+ in: path
1645+ name: id
1646+ required: true
1647+ type: string
1648+ - description: MCP get prompt request
1649+ in: body
1650+ name: request
1651+ required: true
1652+ schema:
1653+ $ref: '#/definitions/proto.MCPGetPromptRequest'
1654+ produces:
1655+ - application/json
1656+ responses:
1657+ "200":
1658+ description: OK
1659+ schema:
1660+ $ref: '#/definitions/proto.MCPGetPromptResponse'
1661+ "400":
1662+ description: Bad Request
1663+ schema:
1664+ $ref: '#/definitions/proto.Error'
1665+ "404":
1666+ description: Not Found
1667+ schema:
1668+ $ref: '#/definitions/proto.Error'
1669+ "500":
1670+ description: Internal Server Error
1671+ schema:
1672+ $ref: '#/definitions/proto.Error'
1673+ summary: Get MCP prompt
1674+ tags:
1675+ - mcp
1676+ /workspaces/{id}/mcp/read-resource:
1677+ post:
1678+ consumes:
1679+ - application/json
1680+ parameters:
1681+ - description: Workspace ID
1682+ in: path
1683+ name: id
1684+ required: true
1685+ type: string
1686+ - description: MCP read resource request
1687+ in: body
1688+ name: request
1689+ required: true
1690+ schema:
1691+ $ref: '#/definitions/proto.MCPReadResourceRequest'
1692+ produces:
1693+ - application/json
1694+ responses:
1695+ "200":
1696+ description: OK
1697+ schema:
1698+ type: object
1699+ "400":
1700+ description: Bad Request
1701+ schema:
1702+ $ref: '#/definitions/proto.Error'
1703+ "404":
1704+ description: Not Found
1705+ schema:
1706+ $ref: '#/definitions/proto.Error'
1707+ "500":
1708+ description: Internal Server Error
1709+ schema:
1710+ $ref: '#/definitions/proto.Error'
1711+ summary: Read MCP resource
1712+ tags:
1713+ - mcp
1714+ /workspaces/{id}/mcp/refresh-prompts:
1715+ post:
1716+ consumes:
1717+ - application/json
1718+ parameters:
1719+ - description: Workspace ID
1720+ in: path
1721+ name: id
1722+ required: true
1723+ type: string
1724+ - description: MCP name request
1725+ in: body
1726+ name: request
1727+ required: true
1728+ schema:
1729+ $ref: '#/definitions/proto.MCPNameRequest'
1730+ responses:
1731+ "200":
1732+ description: OK
1733+ "400":
1734+ description: Bad Request
1735+ schema:
1736+ $ref: '#/definitions/proto.Error'
1737+ "404":
1738+ description: Not Found
1739+ schema:
1740+ $ref: '#/definitions/proto.Error'
1741+ "500":
1742+ description: Internal Server Error
1743+ schema:
1744+ $ref: '#/definitions/proto.Error'
1745+ summary: Refresh MCP prompts
1746+ tags:
1747+ - mcp
1748+ /workspaces/{id}/mcp/refresh-resources:
1749+ post:
1750+ consumes:
1751+ - application/json
1752+ parameters:
1753+ - description: Workspace ID
1754+ in: path
1755+ name: id
1756+ required: true
1757+ type: string
1758+ - description: MCP name request
1759+ in: body
1760+ name: request
1761+ required: true
1762+ schema:
1763+ $ref: '#/definitions/proto.MCPNameRequest'
1764+ responses:
1765+ "200":
1766+ description: OK
1767+ "400":
1768+ description: Bad Request
1769+ schema:
1770+ $ref: '#/definitions/proto.Error'
1771+ "404":
1772+ description: Not Found
1773+ schema:
1774+ $ref: '#/definitions/proto.Error'
1775+ "500":
1776+ description: Internal Server Error
1777+ schema:
1778+ $ref: '#/definitions/proto.Error'
1779+ summary: Refresh MCP resources
1780+ tags:
1781+ - mcp
1782+ /workspaces/{id}/mcp/refresh-tools:
1783+ post:
1784+ consumes:
1785+ - application/json
1786+ parameters:
1787+ - description: Workspace ID
1788+ in: path
1789+ name: id
1790+ required: true
1791+ type: string
1792+ - description: MCP name request
1793+ in: body
1794+ name: request
1795+ required: true
1796+ schema:
1797+ $ref: '#/definitions/proto.MCPNameRequest'
1798+ responses:
1799+ "200":
1800+ description: OK
1801+ "400":
1802+ description: Bad Request
1803+ schema:
1804+ $ref: '#/definitions/proto.Error'
1805+ "404":
1806+ description: Not Found
1807+ schema:
1808+ $ref: '#/definitions/proto.Error'
1809+ "500":
1810+ description: Internal Server Error
1811+ schema:
1812+ $ref: '#/definitions/proto.Error'
1813+ summary: Refresh MCP tools
1814+ tags:
1815+ - mcp
1816+ /workspaces/{id}/mcp/states:
1817+ get:
1818+ parameters:
1819+ - description: Workspace ID
1820+ in: path
1821+ name: id
1822+ required: true
1823+ type: string
1824+ produces:
1825+ - application/json
1826+ responses:
1827+ "200":
1828+ description: OK
1829+ schema:
1830+ additionalProperties:
1831+ $ref: '#/definitions/proto.MCPClientInfo'
1832+ type: object
1833+ "404":
1834+ description: Not Found
1835+ schema:
1836+ $ref: '#/definitions/proto.Error'
1837+ "500":
1838+ description: Internal Server Error
1839+ schema:
1840+ $ref: '#/definitions/proto.Error'
1841+ summary: Get MCP client states
1842+ tags:
1843+ - mcp
1844+ /workspaces/{id}/messages/user:
1845+ get:
1846+ parameters:
1847+ - description: Workspace ID
1848+ in: path
1849+ name: id
1850+ required: true
1851+ type: string
1852+ produces:
1853+ - application/json
1854+ responses:
1855+ "200":
1856+ description: OK
1857+ schema:
1858+ items:
1859+ $ref: '#/definitions/github_com_charmbracelet_crush_internal_proto.Message'
1860+ type: array
1861+ "404":
1862+ description: Not Found
1863+ schema:
1864+ $ref: '#/definitions/proto.Error'
1865+ "500":
1866+ description: Internal Server Error
1867+ schema:
1868+ $ref: '#/definitions/proto.Error'
1869+ summary: Get all user messages for workspace
1870+ tags:
1871+ - workspaces
1872+ /workspaces/{id}/permissions/grant:
1873+ post:
1874+ consumes:
1875+ - application/json
1876+ parameters:
1877+ - description: Workspace ID
1878+ in: path
1879+ name: id
1880+ required: true
1881+ type: string
1882+ - description: Permission grant
1883+ in: body
1884+ name: request
1885+ required: true
1886+ schema:
1887+ $ref: '#/definitions/proto.PermissionGrant'
1888+ responses:
1889+ "200":
1890+ description: OK
1891+ "400":
1892+ description: Bad Request
1893+ schema:
1894+ $ref: '#/definitions/proto.Error'
1895+ "404":
1896+ description: Not Found
1897+ schema:
1898+ $ref: '#/definitions/proto.Error'
1899+ "500":
1900+ description: Internal Server Error
1901+ schema:
1902+ $ref: '#/definitions/proto.Error'
1903+ summary: Grant permission
1904+ tags:
1905+ - permissions
1906+ /workspaces/{id}/permissions/skip:
1907+ get:
1908+ parameters:
1909+ - description: Workspace ID
1910+ in: path
1911+ name: id
1912+ required: true
1913+ type: string
1914+ produces:
1915+ - application/json
1916+ responses:
1917+ "200":
1918+ description: OK
1919+ schema:
1920+ $ref: '#/definitions/proto.PermissionSkipRequest'
1921+ "404":
1922+ description: Not Found
1923+ schema:
1924+ $ref: '#/definitions/proto.Error'
1925+ "500":
1926+ description: Internal Server Error
1927+ schema:
1928+ $ref: '#/definitions/proto.Error'
1929+ summary: Get skip permissions status
1930+ tags:
1931+ - permissions
1932+ post:
1933+ consumes:
1934+ - application/json
1935+ parameters:
1936+ - description: Workspace ID
1937+ in: path
1938+ name: id
1939+ required: true
1940+ type: string
1941+ - description: Permission skip request
1942+ in: body
1943+ name: request
1944+ required: true
1945+ schema:
1946+ $ref: '#/definitions/proto.PermissionSkipRequest'
1947+ responses:
1948+ "200":
1949+ description: OK
1950+ "400":
1951+ description: Bad Request
1952+ schema:
1953+ $ref: '#/definitions/proto.Error'
1954+ "404":
1955+ description: Not Found
1956+ schema:
1957+ $ref: '#/definitions/proto.Error'
1958+ "500":
1959+ description: Internal Server Error
1960+ schema:
1961+ $ref: '#/definitions/proto.Error'
1962+ summary: Set skip permissions
1963+ tags:
1964+ - permissions
1965+ /workspaces/{id}/project/init:
1966+ post:
1967+ parameters:
1968+ - description: Workspace ID
1969+ in: path
1970+ name: id
1971+ required: true
1972+ type: string
1973+ responses:
1974+ "200":
1975+ description: OK
1976+ "404":
1977+ description: Not Found
1978+ schema:
1979+ $ref: '#/definitions/proto.Error'
1980+ "500":
1981+ description: Internal Server Error
1982+ schema:
1983+ $ref: '#/definitions/proto.Error'
1984+ summary: Mark project as initialized
1985+ tags:
1986+ - project
1987+ /workspaces/{id}/project/init-prompt:
1988+ get:
1989+ parameters:
1990+ - description: Workspace ID
1991+ in: path
1992+ name: id
1993+ required: true
1994+ type: string
1995+ produces:
1996+ - application/json
1997+ responses:
1998+ "200":
1999+ description: OK
2000+ schema:
2001+ $ref: '#/definitions/proto.ProjectInitPromptResponse'
2002+ "404":
2003+ description: Not Found
2004+ schema:
2005+ $ref: '#/definitions/proto.Error'
2006+ "500":
2007+ description: Internal Server Error
2008+ schema:
2009+ $ref: '#/definitions/proto.Error'
2010+ summary: Get project initialization prompt
2011+ tags:
2012+ - project
2013+ /workspaces/{id}/project/needs-init:
2014+ get:
2015+ parameters:
2016+ - description: Workspace ID
2017+ in: path
2018+ name: id
2019+ required: true
2020+ type: string
2021+ produces:
2022+ - application/json
2023+ responses:
2024+ "200":
2025+ description: OK
2026+ schema:
2027+ $ref: '#/definitions/proto.ProjectNeedsInitResponse'
2028+ "404":
2029+ description: Not Found
2030+ schema:
2031+ $ref: '#/definitions/proto.Error'
2032+ "500":
2033+ description: Internal Server Error
2034+ schema:
2035+ $ref: '#/definitions/proto.Error'
2036+ summary: Check if project needs initialization
2037+ tags:
2038+ - project
2039+ /workspaces/{id}/providers:
2040+ get:
2041+ parameters:
2042+ - description: Workspace ID
2043+ in: path
2044+ name: id
2045+ required: true
2046+ type: string
2047+ produces:
2048+ - application/json
2049+ responses:
2050+ "200":
2051+ description: OK
2052+ schema:
2053+ type: object
2054+ "404":
2055+ description: Not Found
2056+ schema:
2057+ $ref: '#/definitions/proto.Error'
2058+ "500":
2059+ description: Internal Server Error
2060+ schema:
2061+ $ref: '#/definitions/proto.Error'
2062+ summary: Get workspace providers
2063+ tags:
2064+ - workspaces
2065+ /workspaces/{id}/sessions:
2066+ get:
2067+ parameters:
2068+ - description: Workspace ID
2069+ in: path
2070+ name: id
2071+ required: true
2072+ type: string
2073+ produces:
2074+ - application/json
2075+ responses:
2076+ "200":
2077+ description: OK
2078+ schema:
2079+ items:
2080+ $ref: '#/definitions/proto.Session'
2081+ type: array
2082+ "404":
2083+ description: Not Found
2084+ schema:
2085+ $ref: '#/definitions/proto.Error'
2086+ "500":
2087+ description: Internal Server Error
2088+ schema:
2089+ $ref: '#/definitions/proto.Error'
2090+ summary: List sessions
2091+ tags:
2092+ - sessions
2093+ post:
2094+ consumes:
2095+ - application/json
2096+ parameters:
2097+ - description: Workspace ID
2098+ in: path
2099+ name: id
2100+ required: true
2101+ type: string
2102+ - description: Session creation params (title)
2103+ in: body
2104+ name: request
2105+ required: true
2106+ schema:
2107+ $ref: '#/definitions/proto.Session'
2108+ produces:
2109+ - application/json
2110+ responses:
2111+ "200":
2112+ description: OK
2113+ schema:
2114+ $ref: '#/definitions/proto.Session'
2115+ "400":
2116+ description: Bad Request
2117+ schema:
2118+ $ref: '#/definitions/proto.Error'
2119+ "404":
2120+ description: Not Found
2121+ schema:
2122+ $ref: '#/definitions/proto.Error'
2123+ "500":
2124+ description: Internal Server Error
2125+ schema:
2126+ $ref: '#/definitions/proto.Error'
2127+ summary: Create session
2128+ tags:
2129+ - sessions
2130+ /workspaces/{id}/sessions/{sid}:
2131+ delete:
2132+ parameters:
2133+ - description: Workspace ID
2134+ in: path
2135+ name: id
2136+ required: true
2137+ type: string
2138+ - description: Session ID
2139+ in: path
2140+ name: sid
2141+ required: true
2142+ type: string
2143+ responses:
2144+ "200":
2145+ description: OK
2146+ "404":
2147+ description: Not Found
2148+ schema:
2149+ $ref: '#/definitions/proto.Error'
2150+ "500":
2151+ description: Internal Server Error
2152+ schema:
2153+ $ref: '#/definitions/proto.Error'
2154+ summary: Delete session
2155+ tags:
2156+ - sessions
2157+ get:
2158+ parameters:
2159+ - description: Workspace ID
2160+ in: path
2161+ name: id
2162+ required: true
2163+ type: string
2164+ - description: Session ID
2165+ in: path
2166+ name: sid
2167+ required: true
2168+ type: string
2169+ produces:
2170+ - application/json
2171+ responses:
2172+ "200":
2173+ description: OK
2174+ schema:
2175+ $ref: '#/definitions/proto.Session'
2176+ "404":
2177+ description: Not Found
2178+ schema:
2179+ $ref: '#/definitions/proto.Error'
2180+ "500":
2181+ description: Internal Server Error
2182+ schema:
2183+ $ref: '#/definitions/proto.Error'
2184+ summary: Get session
2185+ tags:
2186+ - sessions
2187+ put:
2188+ consumes:
2189+ - application/json
2190+ parameters:
2191+ - description: Workspace ID
2192+ in: path
2193+ name: id
2194+ required: true
2195+ type: string
2196+ - description: Session ID
2197+ in: path
2198+ name: sid
2199+ required: true
2200+ type: string
2201+ - description: Updated session
2202+ in: body
2203+ name: request
2204+ required: true
2205+ schema:
2206+ $ref: '#/definitions/proto.Session'
2207+ produces:
2208+ - application/json
2209+ responses:
2210+ "200":
2211+ description: OK
2212+ schema:
2213+ $ref: '#/definitions/proto.Session'
2214+ "400":
2215+ description: Bad Request
2216+ schema:
2217+ $ref: '#/definitions/proto.Error'
2218+ "404":
2219+ description: Not Found
2220+ schema:
2221+ $ref: '#/definitions/proto.Error'
2222+ "500":
2223+ description: Internal Server Error
2224+ schema:
2225+ $ref: '#/definitions/proto.Error'
2226+ summary: Update session
2227+ tags:
2228+ - sessions
2229+ /workspaces/{id}/sessions/{sid}/filetracker/files:
2230+ get:
2231+ parameters:
2232+ - description: Workspace ID
2233+ in: path
2234+ name: id
2235+ required: true
2236+ type: string
2237+ - description: Session ID
2238+ in: path
2239+ name: sid
2240+ required: true
2241+ type: string
2242+ produces:
2243+ - application/json
2244+ responses:
2245+ "200":
2246+ description: OK
2247+ schema:
2248+ items:
2249+ type: string
2250+ type: array
2251+ "404":
2252+ description: Not Found
2253+ schema:
2254+ $ref: '#/definitions/proto.Error'
2255+ "500":
2256+ description: Internal Server Error
2257+ schema:
2258+ $ref: '#/definitions/proto.Error'
2259+ summary: List tracked files for session
2260+ tags:
2261+ - filetracker
2262+ /workspaces/{id}/sessions/{sid}/history:
2263+ get:
2264+ parameters:
2265+ - description: Workspace ID
2266+ in: path
2267+ name: id
2268+ required: true
2269+ type: string
2270+ - description: Session ID
2271+ in: path
2272+ name: sid
2273+ required: true
2274+ type: string
2275+ produces:
2276+ - application/json
2277+ responses:
2278+ "200":
2279+ description: OK
2280+ schema:
2281+ items:
2282+ $ref: '#/definitions/proto.File'
2283+ type: array
2284+ "404":
2285+ description: Not Found
2286+ schema:
2287+ $ref: '#/definitions/proto.Error'
2288+ "500":
2289+ description: Internal Server Error
2290+ schema:
2291+ $ref: '#/definitions/proto.Error'
2292+ summary: Get session history
2293+ tags:
2294+ - sessions
2295+ /workspaces/{id}/sessions/{sid}/messages:
2296+ get:
2297+ parameters:
2298+ - description: Workspace ID
2299+ in: path
2300+ name: id
2301+ required: true
2302+ type: string
2303+ - description: Session ID
2304+ in: path
2305+ name: sid
2306+ required: true
2307+ type: string
2308+ produces:
2309+ - application/json
2310+ responses:
2311+ "200":
2312+ description: OK
2313+ schema:
2314+ items:
2315+ $ref: '#/definitions/github_com_charmbracelet_crush_internal_proto.Message'
2316+ type: array
2317+ "404":
2318+ description: Not Found
2319+ schema:
2320+ $ref: '#/definitions/proto.Error'
2321+ "500":
2322+ description: Internal Server Error
2323+ schema:
2324+ $ref: '#/definitions/proto.Error'
2325+ summary: Get session messages
2326+ tags:
2327+ - sessions
2328+ /workspaces/{id}/sessions/{sid}/messages/user:
2329+ get:
2330+ parameters:
2331+ - description: Workspace ID
2332+ in: path
2333+ name: id
2334+ required: true
2335+ type: string
2336+ - description: Session ID
2337+ in: path
2338+ name: sid
2339+ required: true
2340+ type: string
2341+ produces:
2342+ - application/json
2343+ responses:
2344+ "200":
2345+ description: OK
2346+ schema:
2347+ items:
2348+ $ref: '#/definitions/github_com_charmbracelet_crush_internal_proto.Message'
2349+ type: array
2350+ "404":
2351+ description: Not Found
2352+ schema:
2353+ $ref: '#/definitions/proto.Error'
2354+ "500":
2355+ description: Internal Server Error
2356+ schema:
2357+ $ref: '#/definitions/proto.Error'
2358+ summary: Get user messages for session
2359+ tags:
2360+ - sessions
2361+swagger: "2.0"
internal/ui/common/common.go+7−12
@@ -7,10 +7,10 @@ import (
7
7
8 tea "charm.land/bubbletea/v2"
8 tea "charm.land/bubbletea/v2"
9 "github.com/atotto/clipboard"
9 "github.com/atotto/clipboard"
10− "github.com/charmbracelet/crush/internal/app"
11 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/ui/styles"
11 "github.com/charmbracelet/crush/internal/ui/styles"
13 "github.com/charmbracelet/crush/internal/ui/util"
12 "github.com/charmbracelet/crush/internal/ui/util"
13+ "github.com/charmbracelet/crush/internal/workspace"
14 uv "github.com/charmbracelet/ultraviolet"
14 uv "github.com/charmbracelet/ultraviolet"
15)
15)
16
16
@@ -22,26 +22,21 @@ var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
22
22
23// Common defines common UI options and configurations.
23// Common defines common UI options and configurations.
24type Common struct {
24type Common struct {
25− App *app.App
25+ Workspace workspace.Workspace
26− Styles *styles.Styles
26+ Styles *styles.Styles
27}
27}
28
28
29// Config returns the pure-data configuration associated with this [Common] instance.
29// Config returns the pure-data configuration associated with this [Common] instance.
30func (c *Common) Config() *config.Config {
30func (c *Common) Config() *config.Config {
31− return c.App.Config()
31+ return c.Workspace.Config()
32−}
33−
34−// Store returns the config store associated with this [Common] instance.
35−func (c *Common) Store() *config.ConfigStore {
36− return c.App.Store()
37}
32}
38
33
39// DefaultCommon returns the default common UI configurations.
34// DefaultCommon returns the default common UI configurations.
40−func DefaultCommon(app *app.App) *Common {
35+func DefaultCommon(ws workspace.Workspace) *Common {
41 s := styles.DefaultStyles()
36 s := styles.DefaultStyles()
42 return &Common{
37 return &Common{
43− App: app,
38+ Workspace: ws,
44− Styles: &s,
39+ Styles: &s,
45 }
40 }
46}
41}
47
42
internal/ui/dialog/api_key_input.go+2−4
@@ -291,7 +291,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
291 Type: m.provider.Type,
291 Type: m.provider.Type,
292 BaseURL: m.provider.APIEndpoint,
292 BaseURL: m.provider.APIEndpoint,
293 }
293 }
294− err := providerConfig.TestConnection(m.com.Store().Resolver())
294+ err := providerConfig.TestConnection(m.com.Workspace.Resolver())
295
295
296 // intentionally wait for at least 750ms to make sure the user sees the spinner
296 // intentionally wait for at least 750ms to make sure the user sees the spinner
297 elapsed := time.Since(start)
297 elapsed := time.Since(start)
@@ -307,9 +307,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
307}
307}
308
308
309func (m *APIKeyInput) saveKeyAndContinue() Action {
309func (m *APIKeyInput) saveKeyAndContinue() Action {
310− store := m.com.Store()
311−
312− err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value())
310+ err := m.com.Workspace.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value())
313 if err != nil {
311 if err != nil {
314 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
312 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
315 }
313 }
internal/ui/dialog/filepicker.go+1−1
@@ -123,7 +123,7 @@ func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
123
123
124// WorkingDir returns the current working directory of the [FilePicker].
124// WorkingDir returns the current working directory of the [FilePicker].
125func (f *FilePicker) WorkingDir() string {
125func (f *FilePicker) WorkingDir() string {
126− wd := f.com.Store().WorkingDir()
126+ wd := f.com.Workspace.WorkingDir()
127 if len(wd) > 0 {
127 if len(wd) > 0 {
128 return wd
128 return wd
129 }
129 }
internal/ui/dialog/models.go+1−1
@@ -485,7 +485,7 @@ func (m *Models) setProviderItems() error {
485
485
486 if len(validRecentItems) != len(recentItems) {
486 if len(validRecentItems) != len(recentItems) {
487 // FIXME: Does this need to be here? Is it mutating the config during a read?
487 // FIXME: Does this need to be here? Is it mutating the config during a read?
488− if err := m.com.Store().SetConfigField(config.ScopeGlobal, fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
488+ if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
489 return fmt.Errorf("failed to update recent models: %w", err)
489 return fmt.Errorf("failed to update recent models: %w", err)
490 }
490 }
491 }
491 }
internal/ui/dialog/oauth.go+1−3
@@ -373,9 +373,7 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
373}
373}
374
374
375func (m *OAuth) saveKeyAndContinue() Action {
375func (m *OAuth) saveKeyAndContinue() Action {
376− store := m.com.Store()
377−
378− err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token)
376+ err := m.com.Workspace.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token)
379 if err != nil {
377 if err != nil {
380 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
378 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
381 }
379 }
internal/ui/dialog/sessions.go+5−5
@@ -61,7 +61,7 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
61 s := new(Session)
61 s := new(Session)
62 s.sessionsMode = sessionsModeNormal
62 s.sessionsMode = sessionsModeNormal
63 s.com = com
63 s.com = com
64− sessions, err := com.App.Sessions.List(context.TODO())
64+ sessions, err := com.Workspace.ListSessions(context.TODO())
65 if err != nil {
65 if err != nil {
66 return nil, err
66 return nil, err
67 }
67 }
@@ -349,7 +349,7 @@ func (s *Session) removeSession(id string) {
349
349
350func (s *Session) deleteSessionCmd(id string) tea.Cmd {
350func (s *Session) deleteSessionCmd(id string) tea.Cmd {
351 return func() tea.Msg {
351 return func() tea.Msg {
352− err := s.com.App.Sessions.Delete(context.TODO(), id)
352+ err := s.com.Workspace.DeleteSession(context.TODO(), id)
353 if err != nil {
353 if err != nil {
354 return util.NewErrorMsg(err)
354 return util.NewErrorMsg(err)
355 }
355 }
@@ -385,7 +385,7 @@ func (s *Session) updateSession(session session.Session) {
385
385
386func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
386func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
387 return func() tea.Msg {
387 return func() tea.Msg {
388− _, err := s.com.App.Sessions.Save(context.TODO(), session)
388+ _, err := s.com.Workspace.SaveSession(context.TODO(), session)
389 if err != nil {
389 if err != nil {
390 return util.NewErrorMsg(err)
390 return util.NewErrorMsg(err)
391 }
391 }
@@ -399,11 +399,11 @@ func (s *Session) isCurrentSessionBusy() bool {
399 return false
399 return false
400 }
400 }
401
401
402− if s.com.App.AgentCoordinator == nil {
402+ if !s.com.Workspace.AgentIsReady() {
403 return false
403 return false
404 }
404 }
405
405
406− return s.com.App.AgentCoordinator.IsSessionBusy(sessionItem.ID())
406+ return s.com.Workspace.AgentIsSessionBusy(sessionItem.ID())
407}
407}
408
408
409// ShortHelp implements [help.KeyMap].
409// ShortHelp implements [help.KeyMap].
internal/ui/model/header.go+15−16
@@ -6,9 +6,7 @@ import (
6
6
7 "charm.land/lipgloss/v2"
7 "charm.land/lipgloss/v2"
8 "github.com/charmbracelet/crush/internal/config"
8 "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/csync"
10 "github.com/charmbracelet/crush/internal/fsext"
9 "github.com/charmbracelet/crush/internal/fsext"
11− "github.com/charmbracelet/crush/internal/lsp"
12 "github.com/charmbracelet/crush/internal/session"
10 "github.com/charmbracelet/crush/internal/session"
13 "github.com/charmbracelet/crush/internal/ui/common"
11 "github.com/charmbracelet/crush/internal/ui/common"
14 "github.com/charmbracelet/crush/internal/ui/styles"
12 "github.com/charmbracelet/crush/internal/ui/styles"
@@ -62,7 +60,7 @@ func (h *header) drawHeader(
62 h.width = width
60 h.width = width
63 h.compact = compact
61 h.compact = compact
64
62
65− if !compact || session == nil || h.com.App == nil {
63+ if !compact || session == nil {
66 uv.NewStyledString(h.logo).Draw(scr, area)
64 uv.NewStyledString(h.logo).Draw(scr, area)
67 return
65 return
68 }
66 }
@@ -75,10 +73,14 @@ func (h *header) drawHeader(
75 b.WriteString(h.compactLogo)
73 b.WriteString(h.compactLogo)
76
74
77 availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - diagToDetailsSpacing
75 availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - diagToDetailsSpacing
76+ lspErrorCount := 0
77+ for _, info := range h.com.Workspace.LSPGetStates() {
78+ lspErrorCount += info.DiagnosticCount
79+ }
78 details := renderHeaderDetails(
80 details := renderHeaderDetails(
79 h.com,
81 h.com,
80 session,
82 session,
81− h.com.App.LSPManager.Clients(),
83+ lspErrorCount,
82 detailsOpen,
84 detailsOpen,
83 availDetailWidth,
85 availDetailWidth,
84 )
86 )
@@ -108,7 +110,7 @@ func (h *header) drawHeader(
108func renderHeaderDetails(
110func renderHeaderDetails(
109 com *common.Common,
111 com *common.Common,
110 session *session.Session,
112 session *session.Session,
111− lspClients *csync.Map[string, *lsp.Client],
113+ lspErrorCount int,
112 detailsOpen bool,
114 detailsOpen bool,
113 availWidth int,
115 availWidth int,
114) string {
116) string {
@@ -116,20 +118,17 @@ func renderHeaderDetails(
116
118
117 var parts []string
119 var parts []string
118
120
119− errorCount := 0
121+ if lspErrorCount > 0 {
120− for l := range lspClients.Seq() {
121− errorCount += l.GetDiagnosticCounts().Error
122− }
123−
124− if errorCount > 0 {
125− parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, errorCount)))
122+ parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, lspErrorCount)))
126 }
123 }
127
124
128 agentCfg := com.Config().Agents[config.AgentCoder]
125 agentCfg := com.Config().Agents[config.AgentCoder]
129 model := com.Config().GetModelByType(agentCfg.Model)
126 model := com.Config().GetModelByType(agentCfg.Model)
127+ if model != nil && model.ContextWindow > 0 {
130− percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
128+ percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
131− formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
129+ formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
132− parts = append(parts, formattedPercentage)
130+ parts = append(parts, formattedPercentage)
131+ }
133
132
134 const keystroke = "ctrl+d"
133 const keystroke = "ctrl+d"
135 if detailsOpen {
134 if detailsOpen {
@@ -143,7 +142,7 @@ func renderHeaderDetails(
143 metadata = dot + metadata
142 metadata = dot + metadata
144
143
145 const dirTrimLimit = 4
144 const dirTrimLimit = 4
146− cwd := fsext.DirTrim(fsext.PrettyPath(com.Store().WorkingDir()), dirTrimLimit)
145+ cwd := fsext.DirTrim(fsext.PrettyPath(com.Workspace.WorkingDir()), dirTrimLimit)
147 cwd = t.Header.WorkingDir.Render(cwd)
146 cwd = t.Header.WorkingDir.Render(cwd)
148
147
149 result := cwd + metadata
148 result := cwd + metadata
internal/ui/model/history.go+2−2
@@ -22,9 +22,9 @@ func (m *UI) loadPromptHistory() tea.Cmd {
22 var err error
22 var err error
23
23
24 if m.session != nil {
24 if m.session != nil {
25− messages, err = m.com.App.Messages.ListUserMessages(ctx, m.session.ID)
25+ messages, err = m.com.Workspace.ListUserMessages(ctx, m.session.ID)
26 } else {
26 } else {
27− messages, err = m.com.App.Messages.ListAllUserMessages(ctx)
27+ messages, err = m.com.Workspace.ListAllUserMessages(ctx)
28 }
28 }
29 if err != nil {
29 if err != nil {
30 slog.Error("Failed to load prompt history", "error", err)
30 slog.Error("Failed to load prompt history", "error", err)
internal/ui/model/landing.go+5−5
@@ -2,16 +2,16 @@ package model
2
2
3import (
3import (
4 "charm.land/lipgloss/v2"
4 "charm.land/lipgloss/v2"
5− "github.com/charmbracelet/crush/internal/agent"
6 "github.com/charmbracelet/crush/internal/ui/common"
5 "github.com/charmbracelet/crush/internal/ui/common"
6+ "github.com/charmbracelet/crush/internal/workspace"
7 "github.com/charmbracelet/ultraviolet/layout"
7 "github.com/charmbracelet/ultraviolet/layout"
8)
8)
9
9
10// selectedLargeModel returns the currently selected large language model from
10// selectedLargeModel returns the currently selected large language model from
11// the agent coordinator, if one exists.
11// the agent coordinator, if one exists.
12−func (m *UI) selectedLargeModel() *agent.Model {
12+func (m *UI) selectedLargeModel() *workspace.AgentModel {
13− if m.com.App.AgentCoordinator != nil {
13+ if m.com.Workspace.AgentIsReady() {
14− model := m.com.App.AgentCoordinator.Model()
14+ model := m.com.Workspace.AgentModel()
15 return &model
15 return &model
16 }
16 }
17 return nil
17 return nil
@@ -22,7 +22,7 @@ func (m *UI) selectedLargeModel() *agent.Model {
22func (m *UI) landingView() string {
22func (m *UI) landingView() string {
23 t := m.com.Styles
23 t := m.com.Styles
24 width := m.layout.main.Dx()
24 width := m.layout.main.Dx()
25− cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width)
25+ cwd := common.PrettyPath(t, m.com.Workspace.WorkingDir(), width)
26
26
27 parts := []string{
27 parts := []string{
28 cwd,
28 cwd,
internal/ui/model/lsp.go+8−10
@@ -7,16 +7,16 @@ import (
7 "strings"
7 "strings"
8
8
9 "charm.land/lipgloss/v2"
9 "charm.land/lipgloss/v2"
10− "github.com/charmbracelet/crush/internal/app"
11 "github.com/charmbracelet/crush/internal/lsp"
10 "github.com/charmbracelet/crush/internal/lsp"
12 "github.com/charmbracelet/crush/internal/ui/common"
11 "github.com/charmbracelet/crush/internal/ui/common"
13 "github.com/charmbracelet/crush/internal/ui/styles"
12 "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/crush/internal/workspace"
14 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
14 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
15)
15)
16
16
17// LSPInfo wraps LSP client information with diagnostic counts by severity.
17// LSPInfo wraps LSP client information with diagnostic counts by severity.
18type LSPInfo struct {
18type LSPInfo struct {
19− app.LSPClientInfo
19+ workspace.LSPClientInfo
20 Diagnostics map[protocol.DiagnosticSeverity]int
20 Diagnostics map[protocol.DiagnosticSeverity]int
21}
21}
22
22
@@ -25,20 +25,18 @@ type LSPInfo struct {
25func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
25func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
26 t := m.com.Styles
26 t := m.com.Styles
27
27
28− states := slices.SortedFunc(maps.Values(m.lspStates), func(a, b app.LSPClientInfo) int {
28+ states := slices.SortedFunc(maps.Values(m.lspStates), func(a, b workspace.LSPClientInfo) int {
29 return strings.Compare(a.Name, b.Name)
29 return strings.Compare(a.Name, b.Name)
30 })
30 })
31
31
32 var lsps []LSPInfo
32 var lsps []LSPInfo
33 for _, state := range states {
33 for _, state := range states {
34 lspErrs := map[protocol.DiagnosticSeverity]int{}
34 lspErrs := map[protocol.DiagnosticSeverity]int{}
35− if client, ok := m.com.App.LSPManager.Clients().Get(state.Name); ok {
35+ counts := m.com.Workspace.LSPGetDiagnosticCounts(state.Name)
36− counts := client.GetDiagnosticCounts()
37− lspErrs[protocol.SeverityError] = counts.Error
36+ lspErrs[protocol.SeverityError] = counts.Error
38− lspErrs[protocol.SeverityWarning] = counts.Warning
37+ lspErrs[protocol.SeverityWarning] = counts.Warning
39− lspErrs[protocol.SeverityHint] = counts.Hint
38+ lspErrs[protocol.SeverityHint] = counts.Hint
40− lspErrs[protocol.SeverityInformation] = counts.Information
39+ lspErrs[protocol.SeverityInformation] = counts.Information
41− }
42
40
43 lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
41 lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
44 }
42 }
internal/ui/model/onboarding.go+7−8
@@ -9,8 +9,6 @@ import (
9 tea "charm.land/bubbletea/v2"
9 tea "charm.land/bubbletea/v2"
10 "charm.land/lipgloss/v2"
10 "charm.land/lipgloss/v2"
11
11
12− "github.com/charmbracelet/crush/internal/agent"
13− "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/home"
12 "github.com/charmbracelet/crush/internal/home"
15 "github.com/charmbracelet/crush/internal/ui/common"
13 "github.com/charmbracelet/crush/internal/ui/common"
16 "github.com/charmbracelet/crush/internal/ui/util"
14 "github.com/charmbracelet/crush/internal/ui/util"
@@ -19,7 +17,7 @@ import (
19// markProjectInitialized marks the current project as initialized in the config.
17// markProjectInitialized marks the current project as initialized in the config.
20func (m *UI) markProjectInitialized() tea.Msg {
18func (m *UI) markProjectInitialized() tea.Msg {
21 // TODO: handle error so we show it in the tui footer
19 // TODO: handle error so we show it in the tui footer
22− err := config.MarkProjectInitialized(m.com.Store())
20+ err := m.com.Workspace.MarkProjectInitialized()
23 if err != nil {
21 if err != nil {
24 slog.Error(err.Error())
22 slog.Error(err.Error())
25 }
23 }
@@ -52,12 +50,13 @@ func (m *UI) initializeProject() tea.Cmd {
52 if cmd := m.newSession(); cmd != nil {
50 if cmd := m.newSession(); cmd != nil {
53 cmds = append(cmds, cmd)
51 cmds = append(cmds, cmd)
54 }
52 }
55− cfg := m.com.Store()
56−
57 initialize := func() tea.Msg {
53 initialize := func() tea.Msg {
58− initPrompt, err := agent.InitializePrompt(cfg)
54+ initPrompt, err := m.com.Workspace.InitializePrompt()
59 if err != nil {
55 if err != nil {
56+ return util.InfoMsg{
60− return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()}
57+ Type: util.InfoTypeError,
58+ Msg: fmt.Sprintf("Failed to initialize project: %v", err),
59+ }
61 }
60 }
62 return sendMessageMsg{Content: initPrompt}
61 return sendMessageMsg{Content: initPrompt}
63 }
62 }
@@ -78,7 +77,7 @@ func (m *UI) skipInitializeProject() tea.Cmd {
78// initializeView renders the project initialization prompt with Yes/No buttons.
77// initializeView renders the project initialization prompt with Yes/No buttons.
79func (m *UI) initializeView() string {
78func (m *UI) initializeView() string {
80 s := m.com.Styles.Initialize
79 s := m.com.Styles.Initialize
81− cwd := home.Short(m.com.Store().WorkingDir())
80+ cwd := home.Short(m.com.Workspace.WorkingDir())
82 initFile := m.com.Config().Options.InitializeAs
81 initFile := m.com.Config().Options.InitializeAs
83
82
84 header := s.Header.Render("Would you like to initialize this project?")
83 header := s.Header.Render("Would you like to initialize this project?")
internal/ui/model/pills.go+2−2
@@ -249,8 +249,8 @@ func (m *UI) renderPills() {
249 if todosFocused && hasIncomplete {
249 if todosFocused && hasIncomplete {
250 expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
250 expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
251 } else if queueFocused && hasQueue {
251 } else if queueFocused && hasQueue {
252− if m.com.App != nil && m.com.App.AgentCoordinator != nil {
252+ if m.com.Workspace.AgentIsReady() {
253− queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID)
253+ queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
254 expandedList = queueList(queueItems, t)
254 expandedList = queueList(queueItems, t)
255 }
255 }
256 }
256 }
internal/ui/model/session.go+4−4
@@ -66,7 +66,7 @@ type SessionFile struct {
66// returns a sessionFilesLoadedMsg containing the processed session files.
66// returns a sessionFilesLoadedMsg containing the processed session files.
67func (m *UI) loadSession(sessionID string) tea.Cmd {
67func (m *UI) loadSession(sessionID string) tea.Cmd {
68 return func() tea.Msg {
68 return func() tea.Msg {
69− session, err := m.com.App.Sessions.Get(context.Background(), sessionID)
69+ session, err := m.com.Workspace.GetSession(context.Background(), sessionID)
70 if err != nil {
70 if err != nil {
71 return util.ReportError(err)
71 return util.ReportError(err)
72 }
72 }
@@ -76,7 +76,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
76 return util.ReportError(err)
76 return util.ReportError(err)
77 }
77 }
78
78
79− readFiles, err := m.com.App.FileTracker.ListReadFiles(context.Background(), sessionID)
79+ readFiles, err := m.com.Workspace.FileTrackerListReadFiles(context.Background(), sessionID)
80 if err != nil {
80 if err != nil {
81 slog.Error("Failed to load read files for session", "error", err)
81 slog.Error("Failed to load read files for session", "error", err)
82 }
82 }
@@ -90,7 +90,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
90}
90}
91
91
92func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
92func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
93− files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
93+ files, err := m.com.Workspace.ListSessionHistory(context.Background(), sessionID)
94 if err != nil {
94 if err != nil {
95 return nil, err
95 return nil, err
96 }
96 }
@@ -241,7 +241,7 @@ func (m *UI) startLSPs(paths []string) tea.Cmd {
241 return func() tea.Msg {
241 return func() tea.Msg {
242 ctx := context.Background()
242 ctx := context.Background()
243 for _, path := range paths {
243 for _, path := range paths {
244− m.com.App.LSPManager.Start(ctx, path)
244+ m.com.Workspace.LSPStart(ctx, path)
245 }
245 }
246 return nil
246 return nil
247 }
247 }
internal/ui/model/sidebar.go+7−3
@@ -48,7 +48,11 @@ func (m *UI) modelInfo(width int) string {
48 ModelContext: model.CatwalkCfg.ContextWindow,
48 ModelContext: model.CatwalkCfg.ContextWindow,
49 }
49 }
50 }
50 }
51+ var modelName string
52+ if model != nil {
53+ modelName = model.CatwalkCfg.Name
54+ }
51− return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width)
55+ return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width)
52}
56}
53
57
54// getDynamicHeightLimits will give us the num of items to show in each section based on the hight
58// getDynamicHeightLimits will give us the num of items to show in each section based on the hight
@@ -112,7 +116,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
112 height := area.Dy()
116 height := area.Dy()
113
117
114 title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
118 title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
115− cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width)
119+ cwd := common.PrettyPath(t, m.com.Workspace.WorkingDir(), width)
116 sidebarLogo := m.sidebarLogo
120 sidebarLogo := m.sidebarLogo
117 if height < logoHeightBreakpoint {
121 if height < logoHeightBreakpoint {
118 sidebarLogo = logo.SmallRender(m.com.Styles, width)
122 sidebarLogo = logo.SmallRender(m.com.Styles, width)
@@ -138,7 +142,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
138
142
139 lspSection := m.lspInfo(width, maxLSPs, true)
143 lspSection := m.lspInfo(width, maxLSPs, true)
140 mcpSection := m.mcpInfo(width, maxMCPs, true)
144 mcpSection := m.mcpInfo(width, maxMCPs, true)
141− filesSection := m.filesInfo(m.com.Store().WorkingDir(), width, maxFiles, true)
145+ filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
142
146
143 uv.NewStyledString(
147 uv.NewStyledString(
144 lipgloss.NewStyle().
148 lipgloss.NewStyle().
internal/ui/model/ui.go+71−99
@@ -28,7 +28,6 @@ import (
28 "github.com/charmbracelet/crush/internal/agent/notify"
28 "github.com/charmbracelet/crush/internal/agent/notify"
29 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
29 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
30 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
30 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
31− "github.com/charmbracelet/crush/internal/app"
32 "github.com/charmbracelet/crush/internal/commands"
31 "github.com/charmbracelet/crush/internal/commands"
33 "github.com/charmbracelet/crush/internal/config"
32 "github.com/charmbracelet/crush/internal/config"
34 "github.com/charmbracelet/crush/internal/fsext"
33 "github.com/charmbracelet/crush/internal/fsext"
@@ -50,6 +49,7 @@ import (
50 "github.com/charmbracelet/crush/internal/ui/styles"
49 "github.com/charmbracelet/crush/internal/ui/styles"
51 "github.com/charmbracelet/crush/internal/ui/util"
50 "github.com/charmbracelet/crush/internal/ui/util"
52 "github.com/charmbracelet/crush/internal/version"
51 "github.com/charmbracelet/crush/internal/version"
52+ "github.com/charmbracelet/crush/internal/workspace"
53 uv "github.com/charmbracelet/ultraviolet"
53 uv "github.com/charmbracelet/ultraviolet"
54 "github.com/charmbracelet/ultraviolet/layout"
54 "github.com/charmbracelet/ultraviolet/layout"
55 "github.com/charmbracelet/ultraviolet/screen"
55 "github.com/charmbracelet/ultraviolet/screen"
@@ -213,7 +213,7 @@ type UI struct {
213 }
213 }
214
214
215 // lsp
215 // lsp
216− lspStates map[string]app.LSPClientInfo
216+ lspStates map[string]workspace.LSPClientInfo
217
217
218 // mcp
218 // mcp
219 mcpStates map[string]mcp.ClientInfo
219 mcpStates map[string]mcp.ClientInfo
@@ -315,7 +315,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
315 completions: comp,
315 completions: comp,
316 attachments: attachments,
316 attachments: attachments,
317 todoSpinner: todoSpinner,
317 todoSpinner: todoSpinner,
318− lspStates: make(map[string]app.LSPClientInfo),
318+ lspStates: make(map[string]workspace.LSPClientInfo),
319 mcpStates: make(map[string]mcp.ClientInfo),
319 mcpStates: make(map[string]mcp.ClientInfo),
320 notifyBackend: notification.NoopBackend{},
320 notifyBackend: notification.NoopBackend{},
321 notifyWindowFocused: true,
321 notifyWindowFocused: true,
@@ -340,7 +340,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
340 desiredFocus := uiFocusEditor
340 desiredFocus := uiFocusEditor
341 if !com.Config().IsConfigured() {
341 if !com.Config().IsConfigured() {
342 desiredState = uiOnboarding
342 desiredState = uiOnboarding
343− } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
343+ } else if n, _ := com.Workspace.ProjectNeedsInitialization(); n {
344 desiredState = uiInitialize
344 desiredState = uiInitialize
345 }
345 }
346
346
@@ -386,11 +386,11 @@ func (m *UI) loadInitialSession() tea.Cmd {
386 return m.loadSession(m.initialSessionID)
386 return m.loadSession(m.initialSessionID)
387 case m.continueLastSession:
387 case m.continueLastSession:
388 return func() tea.Msg {
388 return func() tea.Msg {
389− sess, err := m.com.App.Sessions.GetLast(context.Background())
389+ sessions, err := m.com.Workspace.ListSessions(context.Background())
390− if err != nil {
390+ if err != nil || len(sessions) == 0 {
391 return nil
391 return nil
392 }
392 }
393− return m.loadSession(sess.ID)()
393+ return m.loadSession(sessions[0].ID)()
394 }
394 }
395 default:
395 default:
396 return nil
396 return nil
@@ -463,7 +463,7 @@ func (m *UI) loadMCPrompts() tea.Msg {
463func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
463func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
464 var cmds []tea.Cmd
464 var cmds []tea.Cmd
465 if m.hasSession() && m.isAgentBusy() {
465 if m.hasSession() && m.isAgentBusy() {
466− queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
466+ queueSize := m.com.Workspace.AgentQueuedPrompts(m.session.ID)
467 if queueSize != m.promptQueue {
467 if queueSize != m.promptQueue {
468 m.promptQueue = queueSize
468 m.promptQueue = queueSize
469 m.updateLayoutAndSize()
469 m.updateLayoutAndSize()
@@ -498,7 +498,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
498 m.session = msg.session
498 m.session = msg.session
499 m.sessionFiles = msg.files
499 m.sessionFiles = msg.files
500 cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
500 cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
501− msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
501+ msgs, err := m.com.Workspace.ListMessages(context.Background(), m.session.ID)
502 if err != nil {
502 if err != nil {
503 cmds = append(cmds, util.ReportError(err))
503 cmds = append(cmds, util.ReportError(err))
504 break
504 break
@@ -615,8 +615,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
615 m.renderPills()
615 m.renderPills()
616 case pubsub.Event[history.File]:
616 case pubsub.Event[history.File]:
617 cmds = append(cmds, m.handleFileEvent(msg.Payload))
617 cmds = append(cmds, m.handleFileEvent(msg.Payload))
618− case pubsub.Event[app.LSPEvent]:
618+ case pubsub.Event[workspace.LSPEvent]:
619− m.lspStates = app.GetLSPStates()
619+ m.lspStates = m.com.Workspace.LSPGetStates()
620 case pubsub.Event[mcp.Event]:
620 case pubsub.Event[mcp.Event]:
621 switch msg.Payload.Type {
621 switch msg.Payload.Type {
622 case mcp.EventStateChanged:
622 case mcp.EventStateChanged:
@@ -625,11 +625,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
625 m.loadMCPrompts,
625 m.loadMCPrompts,
626 )
626 )
627 case mcp.EventPromptsListChanged:
627 case mcp.EventPromptsListChanged:
628− return m, handleMCPPromptsEvent(msg.Payload.Name)
628+ return m, handleMCPPromptsEvent(m.com.Workspace, msg.Payload.Name)
629 case mcp.EventToolsListChanged:
629 case mcp.EventToolsListChanged:
630− return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
630+ return m, handleMCPToolsEvent(m.com.Workspace, msg.Payload.Name)
631 case mcp.EventResourcesListChanged:
631 case mcp.EventResourcesListChanged:
632− return m, handleMCPResourcesEvent(msg.Payload.Name)
632+ return m, handleMCPResourcesEvent(m.com.Workspace, msg.Payload.Name)
633 }
633 }
634 case pubsub.Event[permission.PermissionRequest]:
634 case pubsub.Event[permission.PermissionRequest]:
635 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
635 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -836,6 +836,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
836 m.textarea.MoveToEnd()
836 m.textarea.MoveToEnd()
837 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
837 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
838 case util.InfoMsg:
838 case util.InfoMsg:
839+ if msg.Type == util.InfoTypeError {
840+ slog.Error("Error reported", "error", msg.Msg)
841+ }
839 m.status.SetInfoMsg(msg)
842 m.status.SetInfoMsg(msg)
840 ttl := msg.TTL
843 ttl := msg.TTL
841 if ttl <= 0 {
844 if ttl <= 0 {
@@ -872,7 +875,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
872 } else {
875 } else {
873 m.textarea.Placeholder = m.readyPlaceholder
876 m.textarea.Placeholder = m.readyPlaceholder
874 }
877 }
875− if m.com.App.Permissions.SkipRequests() {
878+ if m.com.Workspace.PermissionSkipRequests() {
876 m.textarea.Placeholder = "Yolo mode!"
879 m.textarea.Placeholder = "Yolo mode!"
877 }
880 }
878 }
881 }
@@ -951,10 +954,10 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
951 messageID := toolItem.MessageID()
954 messageID := toolItem.MessageID()
952
955
953 // Get the agent tool session ID.
956 // Get the agent tool session ID.
954− agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
957+ agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
955
958
956 // Fetch nested messages.
959 // Fetch nested messages.
957− nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
960+ nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
958 if err != nil || len(nestedMsgs) == 0 {
961 if err != nil || len(nestedMsgs) == 0 {
959 continue
962 continue
960 }
963 }
@@ -1156,7 +1159,7 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.
1156
1159
1157 // Check if this is an agent tool session and parse it.
1160 // Check if this is an agent tool session and parse it.
1158 childSessionID := event.Payload.SessionID
1161 childSessionID := event.Payload.SessionID
1159− _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
1162+ _, toolCallID, ok := m.com.Workspace.ParseAgentToolSessionID(childSessionID)
1160 if !ok {
1163 if !ok {
1161 return nil
1164 return nil
1162 }
1165 }
@@ -1288,8 +1291,8 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1288
1291
1289 // Command dialog messages.
1292 // Command dialog messages.
1290 case dialog.ActionToggleYoloMode:
1293 case dialog.ActionToggleYoloMode:
1291− yolo := !m.com.App.Permissions.SkipRequests()
1294+ yolo := !m.com.Workspace.PermissionSkipRequests()
1292− m.com.App.Permissions.SetSkipRequests(yolo)
1295+ m.com.Workspace.PermissionSetSkipRequests(yolo)
1293 m.setEditorPrompt(yolo)
1296 m.setEditorPrompt(yolo)
1294 m.dialog.CloseDialog(dialog.CommandsID)
1297 m.dialog.CloseDialog(dialog.CommandsID)
1295 case dialog.ActionToggleNotifications:
1298 case dialog.ActionToggleNotifications:
@@ -1297,7 +1300,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1297 if cfg != nil && cfg.Options != nil {
1300 if cfg != nil && cfg.Options != nil {
1298 disabled := !cfg.Options.DisableNotifications
1301 disabled := !cfg.Options.DisableNotifications
1299 cfg.Options.DisableNotifications = disabled
1302 cfg.Options.DisableNotifications = disabled
1300− if err := m.com.Store().SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
1303+ if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
1301 cmds = append(cmds, util.ReportError(err))
1304 cmds = append(cmds, util.ReportError(err))
1302 } else {
1305 } else {
1303 status := "enabled"
1306 status := "enabled"
@@ -1323,7 +1326,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1323 break
1326 break
1324 }
1327 }
1325 cmds = append(cmds, func() tea.Msg {
1328 cmds = append(cmds, func() tea.Msg {
1326− err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
1329+ err := m.com.Workspace.AgentSummarize(context.Background(), msg.SessionID)
1327 if err != nil {
1330 if err != nil {
1328 return util.ReportError(err)()
1331 return util.ReportError(err)()
1329 }
1332 }
@@ -1362,10 +1365,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1362
1365
1363 currentModel := cfg.Models[agentCfg.Model]
1366 currentModel := cfg.Models[agentCfg.Model]
1364 currentModel.Think = !currentModel.Think
1367 currentModel.Think = !currentModel.Think
1365− if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1368+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1366 return util.ReportError(err)()
1369 return util.ReportError(err)()
1367 }
1370 }
1368− m.com.App.UpdateAgentModel(context.TODO())
1371+ m.com.Workspace.UpdateAgentModel(context.TODO())
1369 status := "disabled"
1372 status := "disabled"
1370 if currentModel.Think {
1373 if currentModel.Think {
1371 status = "enabled"
1374 status = "enabled"
@@ -1382,7 +1385,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1382
1385
1383 isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
1386 isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
1384 newValue := !isTransparent
1387 newValue := !isTransparent
1385− if err := m.com.Store().SetTransparentBackground(config.ScopeGlobal, newValue); err != nil {
1388+ if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.tui.transparent", newValue); err != nil {
1386 return util.ReportError(err)()
1389 return util.ReportError(err)()
1387 }
1390 }
1388 m.isTransparent = newValue
1391 m.isTransparent = newValue
@@ -1430,7 +1433,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1430
1433
1431 // Attempt to import GitHub Copilot tokens from VSCode if available.
1434 // Attempt to import GitHub Copilot tokens from VSCode if available.
1432 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1435 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1433− m.com.Store().ImportCopilot()
1436+ m.com.Workspace.ImportCopilot()
1434 }
1437 }
1435
1438
1436 if !isConfigured() || msg.ReAuthenticate {
1439 if !isConfigured() || msg.ReAuthenticate {
@@ -1441,18 +1444,18 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1441 break
1444 break
1442 }
1445 }
1443
1446
1444− if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1447+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
1445 cmds = append(cmds, util.ReportError(err))
1448 cmds = append(cmds, util.ReportError(err))
1446 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1449 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
1447 // Ensure small model is set is unset.
1450 // Ensure small model is set is unset.
1448− smallModel := m.com.App.GetDefaultSmallModel(providerID)
1451+ smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1449− if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1452+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1450 cmds = append(cmds, util.ReportError(err))
1453 cmds = append(cmds, util.ReportError(err))
1451 }
1454 }
1452 }
1455 }
1453
1456
1454 cmds = append(cmds, func() tea.Msg {
1457 cmds = append(cmds, func() tea.Msg {
1455− if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
1458+ if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
1456 return util.ReportError(err)
1459 return util.ReportError(err)
1457 }
1460 }
1458
1461
@@ -1468,7 +1471,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1468 if isOnboarding {
1471 if isOnboarding {
1469 m.setState(uiLanding, uiFocusEditor)
1472 m.setState(uiLanding, uiFocusEditor)
1470 m.com.Config().SetupAgents()
1473 m.com.Config().SetupAgents()
1471− if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
1474+ if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
1472 cmds = append(cmds, util.ReportError(err))
1475 cmds = append(cmds, util.ReportError(err))
1473 }
1476 }
1474 }
1477 }
@@ -1492,13 +1495,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1492
1495
1493 currentModel := cfg.Models[agentCfg.Model]
1496 currentModel := cfg.Models[agentCfg.Model]
1494 currentModel.ReasoningEffort = msg.Effort
1497 currentModel.ReasoningEffort = msg.Effort
1495− if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1498+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
1496 cmds = append(cmds, util.ReportError(err))
1499 cmds = append(cmds, util.ReportError(err))
1497 break
1500 break
1498 }
1501 }
1499
1502
1500 cmds = append(cmds, func() tea.Msg {
1503 cmds = append(cmds, func() tea.Msg {
1501− m.com.App.UpdateAgentModel(context.TODO())
1504+ m.com.Workspace.UpdateAgentModel(context.TODO())
1502 return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1505 return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
1503 })
1506 })
1504 m.dialog.CloseDialog(dialog.ReasoningID)
1507 m.dialog.CloseDialog(dialog.ReasoningID)
@@ -1506,11 +1509,11 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
1506 m.dialog.CloseDialog(dialog.PermissionsID)
1509 m.dialog.CloseDialog(dialog.PermissionsID)
1507 switch msg.Action {
1510 switch msg.Action {
1508 case dialog.PermissionAllow:
1511 case dialog.PermissionAllow:
1509− m.com.App.Permissions.Grant(msg.Permission)
1512+ m.com.Workspace.PermissionGrant(msg.Permission)
1510 case dialog.PermissionAllowForSession:
1513 case dialog.PermissionAllowForSession:
1511− m.com.App.Permissions.GrantPersistent(msg.Permission)
1514+ m.com.Workspace.PermissionGrantPersistent(msg.Permission)
1512 case dialog.PermissionDeny:
1515 case dialog.PermissionDeny:
1513− m.com.App.Permissions.Deny(msg.Permission)
1516+ m.com.Workspace.PermissionDeny(msg.Permission)
1514 }
1517 }
1515
1518
1516 case dialog.ActionFilePickerSelected:
1519 case dialog.ActionFilePickerSelected:
@@ -2115,7 +2118,7 @@ func (m *UI) View() tea.View {
2115 }
2118 }
2116 v.MouseMode = tea.MouseModeCellMotion
2119 v.MouseMode = tea.MouseModeCellMotion
2117 v.ReportFocus = m.caps.ReportFocusEvents
2120 v.ReportFocus = m.caps.ReportFocusEvents
2118− v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
2121+ v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
2119
2122
2120 canvas := uv.NewScreenBuffer(m.width, m.height)
2123 canvas := uv.NewScreenBuffer(m.width, m.height)
2121 v.Cursor = m.Draw(canvas, canvas.Bounds())
2124 v.Cursor = m.Draw(canvas, canvas.Bounds())
@@ -2158,7 +2161,7 @@ func (m *UI) ShortHelp() []key.Binding {
2158 cancelBinding := k.Chat.Cancel
2161 cancelBinding := k.Chat.Cancel
2159 if m.isCanceling {
2162 if m.isCanceling {
2160 cancelBinding.SetHelp("esc", "press again to cancel")
2163 cancelBinding.SetHelp("esc", "press again to cancel")
2161− } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2164+ } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2162 cancelBinding.SetHelp("esc", "clear queue")
2165 cancelBinding.SetHelp("esc", "clear queue")
2163 }
2166 }
2164 binds = append(binds, cancelBinding)
2167 binds = append(binds, cancelBinding)
@@ -2237,7 +2240,7 @@ func (m *UI) FullHelp() [][]key.Binding {
2237 cancelBinding := k.Chat.Cancel
2240 cancelBinding := k.Chat.Cancel
2238 if m.isCanceling {
2241 if m.isCanceling {
2239 cancelBinding.SetHelp("esc", "press again to cancel")
2242 cancelBinding.SetHelp("esc", "press again to cancel")
2240− } else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
2243+ } else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
2241 cancelBinding.SetHelp("esc", "clear queue")
2244 cancelBinding.SetHelp("esc", "clear queue")
2242 }
2245 }
2243 binds = append(binds, []key.Binding{cancelBinding})
2246 binds = append(binds, []key.Binding{cancelBinding})
@@ -2364,7 +2367,7 @@ func (m *UI) currentModelSupportsImages() bool {
2364func (m *UI) toggleCompactMode() tea.Cmd {
2367func (m *UI) toggleCompactMode() tea.Cmd {
2365 m.forceCompactMode = !m.forceCompactMode
2368 m.forceCompactMode = !m.forceCompactMode
2366
2369
2367− err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2370+ err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2368 if err != nil {
2371 if err != nil {
2369 return util.ReportError(err)
2372 return util.ReportError(err)
2370 }
2373 }
@@ -2751,7 +2754,7 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
2751
2754
2752 if m.hasSession() {
2755 if m.hasSession() {
2753 // Skip attachment if file was already read and hasn't been modified.
2756 // Skip attachment if file was already read and hasn't been modified.
2754− lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
2757+ lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
2755 if !lastRead.IsZero() {
2758 if !lastRead.IsZero() {
2756 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2759 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
2757 return nil
2760 return nil
@@ -2792,9 +2795,8 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
2792 heightCmd := m.handleTextareaHeightChange(prevHeight)
2795 heightCmd := m.handleTextareaHeightChange(prevHeight)
2793
2796
2794 resourceCmd := func() tea.Msg {
2797 resourceCmd := func() tea.Msg {
2795− contents, err := mcp.ReadResource(
2798+ contents, err := m.com.Workspace.ReadMCPResource(
2796 context.Background(),
2799 context.Background(),
2797− m.com.Store(),
2798 item.MCPName,
2800 item.MCPName,
2799 item.URI,
2801 item.URI,
2800 )
2802 )
@@ -2863,9 +2865,8 @@ func isWhitespace(b byte) bool {
2863// isAgentBusy returns true if the agent coordinator exists and is currently
2865// isAgentBusy returns true if the agent coordinator exists and is currently
2864// busy processing a request.
2866// busy processing a request.
2865func (m *UI) isAgentBusy() bool {
2867func (m *UI) isAgentBusy() bool {
2866− return m.com.App != nil &&
2868+ return m.com.Workspace.AgentIsReady() &&
2867− m.com.App.AgentCoordinator != nil &&
2869+ m.com.Workspace.AgentIsBusy()
2868− m.com.App.AgentCoordinator.IsBusy()
2869}
2870}
2870
2871
2871// hasSession returns true if there is an active session with a valid ID.
2872// hasSession returns true if there is an active session with a valid ID.
@@ -2922,13 +2923,13 @@ func (m *UI) cacheSidebarLogo(width int) {
2922
2923
2923// sendMessage sends a message with the given content and attachments.
2924// sendMessage sends a message with the given content and attachments.
2924func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2925func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2925− if m.com.App.AgentCoordinator == nil {
2926+ if !m.com.Workspace.AgentIsReady() {
2926 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2927 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
2927 }
2928 }
2928
2929
2929 var cmds []tea.Cmd
2930 var cmds []tea.Cmd
2930 if !m.hasSession() {
2931 if !m.hasSession() {
2931− newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
2932+ newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
2932 if err != nil {
2933 if err != nil {
2933 return util.ReportError(err)
2934 return util.ReportError(err)
2934 }
2935 }
@@ -2945,8 +2946,8 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
2945 ctx := context.Background()
2946 ctx := context.Background()
2946 cmds = append(cmds, func() tea.Msg {
2947 cmds = append(cmds, func() tea.Msg {
2947 for _, path := range m.sessionFileReads {
2948 for _, path := range m.sessionFileReads {
2948− m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2949+ m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
2949− m.com.App.LSPManager.Start(ctx, path)
2950+ m.com.Workspace.LSPStart(ctx, path)
2950 }
2951 }
2951 return nil
2952 return nil
2952 })
2953 })
@@ -2954,7 +2955,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
2954 // Capture session ID to avoid race with main goroutine updating m.session.
2955 // Capture session ID to avoid race with main goroutine updating m.session.
2955 sessionID := m.session.ID
2956 sessionID := m.session.ID
2956 cmds = append(cmds, func() tea.Msg {
2957 cmds = append(cmds, func() tea.Msg {
2957− _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
2958+ err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
2958 if err != nil {
2959 if err != nil {
2959 isCancelErr := errors.Is(err, context.Canceled)
2960 isCancelErr := errors.Is(err, context.Canceled)
2960 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
2961 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
@@ -2963,7 +2964,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
2963 }
2964 }
2964 return util.InfoMsg{
2965 return util.InfoMsg{
2965 Type: util.InfoTypeError,
2966 Type: util.InfoTypeError,
2966− Msg: err.Error(),
2967+ Msg: fmt.Sprintf("Failed to run agent: %v", err),
2967 }
2968 }
2968 }
2969 }
2969 return nil
2970 return nil
@@ -2988,15 +2989,14 @@ func (m *UI) cancelAgent() tea.Cmd {
2988 return nil
2989 return nil
2989 }
2990 }
2990
2991
2991− coordinator := m.com.App.AgentCoordinator
2992+ if !m.com.Workspace.AgentIsReady() {
2992− if coordinator == nil {
2993 return nil
2993 return nil
2994 }
2994 }
2995
2995
2996 if m.isCanceling {
2996 if m.isCanceling {
2997 // Second escape press - actually cancel the agent.
2997 // Second escape press - actually cancel the agent.
2998 m.isCanceling = false
2998 m.isCanceling = false
2999− coordinator.Cancel(m.session.ID)
2999+ m.com.Workspace.AgentCancel(m.session.ID)
3000 // Stop the spinning todo indicator.
3000 // Stop the spinning todo indicator.
3001 m.todoIsSpinning = false
3001 m.todoIsSpinning = false
3002 m.renderPills()
3002 m.renderPills()
@@ -3004,8 +3004,8 @@ func (m *UI) cancelAgent() tea.Cmd {
3004 }
3004 }
3005
3005
3006 // Check if there are queued prompts - if so, clear the queue.
3006 // Check if there are queued prompts - if so, clear the queue.
3007− if coordinator.QueuedPrompts(m.session.ID) > 0 {
3007+ if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
3008− coordinator.ClearQueue(m.session.ID)
3008+ m.com.Workspace.AgentClearQueue(m.session.ID)
3009 return nil
3009 return nil
3010 }
3010 }
3011
3011
@@ -3230,7 +3230,7 @@ func (m *UI) newSession() tea.Cmd {
3230 agenttools.ResetCache()
3230 agenttools.ResetCache()
3231 return tea.Batch(
3231 return tea.Batch(
3232 func() tea.Msg {
3232 func() tea.Msg {
3233− m.com.App.LSPManager.StopAll(context.Background())
3233+ m.com.Workspace.LSPStopAll(context.Background())
3234 return nil
3234 return nil
3235 },
3235 },
3236 m.loadPromptHistory(),
3236 m.loadPromptHistory(),
@@ -3476,7 +3476,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3476
3476
3477 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3477 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
3478 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3478 mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
3479− filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
3479+ filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
3480 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3480 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
3481 uv.NewStyledString(
3481 uv.NewStyledString(
3482 s.CompactDetails.View.
3482 s.CompactDetails.View.
@@ -3494,7 +3494,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
3494
3494
3495func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3495func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
3496 load := func() tea.Msg {
3496 load := func() tea.Msg {
3497− prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
3497+ prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
3498 if err != nil {
3498 if err != nil {
3499 // TODO: make this better
3499 // TODO: make this better
3500 return util.ReportError(err)()
3500 return util.ReportError(err)()
@@ -3521,34 +3521,30 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
3521
3521
3522func (m *UI) handleStateChanged() tea.Cmd {
3522func (m *UI) handleStateChanged() tea.Cmd {
3523 return func() tea.Msg {
3523 return func() tea.Msg {
3524− m.com.App.UpdateAgentModel(context.Background())
3524+ m.com.Workspace.UpdateAgentModel(context.Background())
3525 return mcpStateChangedMsg{
3525 return mcpStateChangedMsg{
3526− states: mcp.GetStates(),
3526+ states: m.com.Workspace.MCPGetStates(),
3527 }
3527 }
3528 }
3528 }
3529}
3529}
3530
3530
3531−func handleMCPPromptsEvent(name string) tea.Cmd {
3531+func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
3532 return func() tea.Msg {
3532 return func() tea.Msg {
3533− mcp.RefreshPrompts(context.Background(), name)
3533+ ws.MCPRefreshPrompts(context.Background(), name)
3534 return nil
3534 return nil
3535 }
3535 }
3536}
3536}
3537
3537
3538−func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3538+func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
3539 return func() tea.Msg {
3539 return func() tea.Msg {
3540− mcp.RefreshTools(
3541− context.Background(),
3540+ ws.RefreshMCPTools(context.Background(), name)
3542− cfg,
3543− name,
3544− )
3545 return nil
3541 return nil
3546 }
3542 }
3547}
3543}
3548
3544
3549−func handleMCPResourcesEvent(name string) tea.Cmd {
3545+func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
3550 return func() tea.Msg {
3546 return func() tea.Msg {
3551− mcp.RefreshResources(context.Background(), name)
3547+ ws.MCPRefreshResources(context.Background(), name)
3552 return nil
3548 return nil
3553 }
3549 }
3554}
3550}
@@ -3566,40 +3562,16 @@ func (m *UI) copyChatHighlight() tea.Cmd {
3566}
3562}
3567
3563
3568func (m *UI) enableDockerMCP() tea.Msg {
3564func (m *UI) enableDockerMCP() tea.Msg {
3569− store := m.com.Store()
3570− // Stage Docker MCP in memory first so startup and persistence can be atomic.
3571− mcpConfig, err := store.PrepareDockerMCPConfig()
3572− if err != nil {
3573− return util.ReportError(err)()
3574− }
3575−
3576 ctx := context.Background()
3565 ctx := context.Background()
3577− if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
3566+ if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
3578− // Roll back runtime and in-memory state when startup fails.
3579− disableErr := mcp.DisableSingle(store, config.DockerMCPName)
3580− delete(store.Config().MCP, config.DockerMCPName)
3581− return util.ReportError(fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr)))()
3567+ return util.ReportError(err)()
3582− }
3583−
3584− if err := store.PersistDockerMCPConfig(mcpConfig); err != nil {
3585− // Roll back runtime and in-memory state if persistence fails.
3586− disableErr := mcp.DisableSingle(store, config.DockerMCPName)
3587− delete(store.Config().MCP, config.DockerMCPName)
3588− return util.ReportError(fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr)))()
3589 }
3568 }
3590
3569
3591 return util.NewInfoMsg("Docker MCP enabled and started successfully")
3570 return util.NewInfoMsg("Docker MCP enabled and started successfully")
3592}
3571}
3593
3572
3594func (m *UI) disableDockerMCP() tea.Msg {
3573func (m *UI) disableDockerMCP() tea.Msg {
3595− store := m.com.Store()
3596− // Close the Docker MCP client.
3597− if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
3598− return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
3599− }
3600−
3601− // Remove from config and persist.
3602− if err := store.DisableDockerMCP(); err != nil {
3574+ if err := m.com.Workspace.DisableDockerMCP(); err != nil {
3603 return util.ReportError(err)()
3575 return util.ReportError(err)()
3604 }
3576 }
3605
3577
internal/ui/model/ui_test.go+9−21
@@ -1,15 +1,13 @@
1package model
1package model
2
2
3import (
3import (
4− "reflect"
5 "testing"
4 "testing"
6− "unsafe"
7
5
8 "charm.land/catwalk/pkg/catwalk"
6 "charm.land/catwalk/pkg/catwalk"
9− "github.com/charmbracelet/crush/internal/app"
10 "github.com/charmbracelet/crush/internal/config"
7 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/csync"
8 "github.com/charmbracelet/crush/internal/csync"
12 "github.com/charmbracelet/crush/internal/ui/common"
9 "github.com/charmbracelet/crush/internal/ui/common"
10+ "github.com/charmbracelet/crush/internal/workspace"
13 "github.com/stretchr/testify/require"
11 "github.com/stretchr/testify/require"
14)
12)
15
13
@@ -79,29 +77,19 @@ func TestCurrentModelSupportsImages(t *testing.T) {
79func newTestUIWithConfig(t *testing.T, cfg *config.Config) *UI {
77func newTestUIWithConfig(t *testing.T, cfg *config.Config) *UI {
80 t.Helper()
78 t.Helper()
81
79
82− store := &config.ConfigStore{}
83− setUnexportedField(t, store, "config", cfg)
84−
85− appInstance := &app.App{}
86− setUnexportedField(t, appInstance, "config", store)
87−
88 return &UI{
80 return &UI{
89 com: &common.Common{
81 com: &common.Common{
90− App: appInstance,
82+ Workspace: &testWorkspace{cfg: cfg},
91 },
83 },
92 }
84 }
93}
85}
94
86
95−func setUnexportedField(t *testing.T, target any, name string, value any) {
87+// testWorkspace is a minimal [workspace.Workspace] stub for unit tests.
96− t.Helper()
88+type testWorkspace struct {
97−
89+ workspace.Workspace
98− v := reflect.ValueOf(target)
90+ cfg *config.Config
99− require.Equal(t, reflect.Pointer, v.Kind())
91+}
100− require.False(t, v.IsNil())
101−
102− field := v.Elem().FieldByName(name)
103− require.Truef(t, field.IsValid(), "field %q not found", name)
104
92
105− fieldValue := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem()
93+func (w *testWorkspace) Config() *config.Config {
106− fieldValue.Set(reflect.ValueOf(value))
94+ return w.cfg
107}
95}
internal/ui/util/util.go−2
@@ -4,7 +4,6 @@ package util
4import (
4import (
5 "context"
5 "context"
6 "errors"
6 "errors"
7− "log/slog"
8 "os/exec"
7 "os/exec"
9 "time"
8 "time"
10
9
@@ -23,7 +22,6 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
23}
22}
24
23
25func ReportError(err error) tea.Cmd {
24func ReportError(err error) tea.Cmd {
26− slog.Error("Error reported", "error", err)
27 return CmdHandler(NewErrorMsg(err))
25 return CmdHandler(NewErrorMsg(err))
28}
26}
29
27
internal/version/version.go+5−2
@@ -2,9 +2,12 @@ package version
2
2
3import "runtime/debug"
3import "runtime/debug"
4
4
5−// Build-time parameters set via -ldflags
5+// Build-time parameters set via -ldflags.
6
6
7+var (
7−var Version = "devel"
8+ Version = "devel"
9+ Commit = "unknown"
10+)
8
11
9// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
12// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
10// without -ldflags, in which case the version above is unset. As a workaround
13// without -ldflags, in which case the version above is unset. As a workaround
internal/workspace/app_workspace.gonew file+389
@@ -0,0 +1,389 @@
1+package workspace
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "time"
8+
9+ tea "charm.land/bubbletea/v2"
10+ "github.com/charmbracelet/crush/internal/agent"
11+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
12+ "github.com/charmbracelet/crush/internal/app"
13+ "github.com/charmbracelet/crush/internal/commands"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/history"
16+ "github.com/charmbracelet/crush/internal/lsp"
17+ "github.com/charmbracelet/crush/internal/message"
18+ "github.com/charmbracelet/crush/internal/oauth"
19+ "github.com/charmbracelet/crush/internal/permission"
20+ "github.com/charmbracelet/crush/internal/session"
21+)
22+
23+// AppWorkspace implements the Workspace interface by delegating
24+// directly to an in-process [app.App] instance. This is the default
25+// mode when the client/server architecture is not enabled.
26+type AppWorkspace struct {
27+ app *app.App
28+ store *config.ConfigStore
29+}
30+
31+// NewAppWorkspace creates a new AppWorkspace wrapping the given app
32+// and config store.
33+func NewAppWorkspace(a *app.App, store *config.ConfigStore) *AppWorkspace {
34+ return &AppWorkspace{
35+ app: a,
36+ store: store,
37+ }
38+}
39+
40+// -- Sessions --
41+
42+func (w *AppWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
43+ return w.app.Sessions.Create(ctx, title)
44+}
45+
46+func (w *AppWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
47+ return w.app.Sessions.Get(ctx, sessionID)
48+}
49+
50+func (w *AppWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
51+ return w.app.Sessions.List(ctx)
52+}
53+
54+func (w *AppWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
55+ return w.app.Sessions.Save(ctx, sess)
56+}
57+
58+func (w *AppWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
59+ return w.app.Sessions.Delete(ctx, sessionID)
60+}
61+
62+func (w *AppWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
63+ return w.app.Sessions.CreateAgentToolSessionID(messageID, toolCallID)
64+}
65+
66+func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
67+ return w.app.Sessions.ParseAgentToolSessionID(sessionID)
68+}
69+
70+// -- Messages --
71+
72+func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
73+ return w.app.Messages.List(ctx, sessionID)
74+}
75+
76+func (w *AppWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
77+ return w.app.Messages.ListUserMessages(ctx, sessionID)
78+}
79+
80+func (w *AppWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
81+ return w.app.Messages.ListAllUserMessages(ctx)
82+}
83+
84+// -- Agent --
85+
86+func (w *AppWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
87+ if w.app.AgentCoordinator == nil {
88+ return errors.New("agent coordinator not initialized")
89+ }
90+ _, err := w.app.AgentCoordinator.Run(ctx, sessionID, prompt, attachments...)
91+ return err
92+}
93+
94+func (w *AppWorkspace) AgentCancel(sessionID string) {
95+ if w.app.AgentCoordinator != nil {
96+ w.app.AgentCoordinator.Cancel(sessionID)
97+ }
98+}
99+
100+func (w *AppWorkspace) AgentIsBusy() bool {
101+ if w.app.AgentCoordinator == nil {
102+ return false
103+ }
104+ return w.app.AgentCoordinator.IsBusy()
105+}
106+
107+func (w *AppWorkspace) AgentIsSessionBusy(sessionID string) bool {
108+ if w.app.AgentCoordinator == nil {
109+ return false
110+ }
111+ return w.app.AgentCoordinator.IsSessionBusy(sessionID)
112+}
113+
114+func (w *AppWorkspace) AgentModel() AgentModel {
115+ if w.app.AgentCoordinator == nil {
116+ return AgentModel{}
117+ }
118+ m := w.app.AgentCoordinator.Model()
119+ return AgentModel{
120+ CatwalkCfg: m.CatwalkCfg,
121+ ModelCfg: m.ModelCfg,
122+ }
123+}
124+
125+func (w *AppWorkspace) AgentIsReady() bool {
126+ return w.app.AgentCoordinator != nil
127+}
128+
129+func (w *AppWorkspace) AgentQueuedPrompts(sessionID string) int {
130+ if w.app.AgentCoordinator == nil {
131+ return 0
132+ }
133+ return w.app.AgentCoordinator.QueuedPrompts(sessionID)
134+}
135+
136+func (w *AppWorkspace) AgentQueuedPromptsList(sessionID string) []string {
137+ if w.app.AgentCoordinator == nil {
138+ return nil
139+ }
140+ return w.app.AgentCoordinator.QueuedPromptsList(sessionID)
141+}
142+
143+func (w *AppWorkspace) AgentClearQueue(sessionID string) {
144+ if w.app.AgentCoordinator != nil {
145+ w.app.AgentCoordinator.ClearQueue(sessionID)
146+ }
147+}
148+
149+func (w *AppWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
150+ if w.app.AgentCoordinator == nil {
151+ return errors.New("agent coordinator not initialized")
152+ }
153+ return w.app.AgentCoordinator.Summarize(ctx, sessionID)
154+}
155+
156+func (w *AppWorkspace) UpdateAgentModel(ctx context.Context) error {
157+ return w.app.UpdateAgentModel(ctx)
158+}
159+
160+func (w *AppWorkspace) InitCoderAgent(ctx context.Context) error {
161+ return w.app.InitCoderAgent(ctx)
162+}
163+
164+func (w *AppWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
165+ return w.app.GetDefaultSmallModel(providerID)
166+}
167+
168+// -- Permissions --
169+
170+func (w *AppWorkspace) PermissionGrant(perm permission.PermissionRequest) {
171+ w.app.Permissions.Grant(perm)
172+}
173+
174+func (w *AppWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
175+ w.app.Permissions.GrantPersistent(perm)
176+}
177+
178+func (w *AppWorkspace) PermissionDeny(perm permission.PermissionRequest) {
179+ w.app.Permissions.Deny(perm)
180+}
181+
182+func (w *AppWorkspace) PermissionSkipRequests() bool {
183+ return w.app.Permissions.SkipRequests()
184+}
185+
186+func (w *AppWorkspace) PermissionSetSkipRequests(skip bool) {
187+ w.app.Permissions.SetSkipRequests(skip)
188+}
189+
190+// -- FileTracker --
191+
192+func (w *AppWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
193+ w.app.FileTracker.RecordRead(ctx, sessionID, path)
194+}
195+
196+func (w *AppWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
197+ return w.app.FileTracker.LastReadTime(ctx, sessionID, path)
198+}
199+
200+func (w *AppWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
201+ return w.app.FileTracker.ListReadFiles(ctx, sessionID)
202+}
203+
204+// -- History --
205+
206+func (w *AppWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
207+ return w.app.History.ListBySession(ctx, sessionID)
208+}
209+
210+// -- LSP --
211+
212+func (w *AppWorkspace) LSPStart(ctx context.Context, path string) {
213+ w.app.LSPManager.Start(ctx, path)
214+}
215+
216+func (w *AppWorkspace) LSPStopAll(ctx context.Context) {
217+ w.app.LSPManager.StopAll(ctx)
218+}
219+
220+func (w *AppWorkspace) LSPGetStates() map[string]LSPClientInfo {
221+ states := app.GetLSPStates()
222+ result := make(map[string]LSPClientInfo, len(states))
223+ for k, v := range states {
224+ result[k] = LSPClientInfo{
225+ Name: v.Name,
226+ State: v.State,
227+ Error: v.Error,
228+ DiagnosticCount: v.DiagnosticCount,
229+ ConnectedAt: v.ConnectedAt,
230+ }
231+ }
232+ return result
233+}
234+
235+func (w *AppWorkspace) LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts {
236+ state, ok := app.GetLSPState(name)
237+ if !ok || state.Client == nil {
238+ return lsp.DiagnosticCounts{}
239+ }
240+ return state.Client.GetDiagnosticCounts()
241+}
242+
243+// -- Config (read-only) --
244+
245+func (w *AppWorkspace) Config() *config.Config {
246+ return w.store.Config()
247+}
248+
249+func (w *AppWorkspace) WorkingDir() string {
250+ return w.store.WorkingDir()
251+}
252+
253+func (w *AppWorkspace) Resolver() config.VariableResolver {
254+ return w.store.Resolver()
255+}
256+
257+// -- Config mutations --
258+
259+func (w *AppWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
260+ return w.store.UpdatePreferredModel(scope, modelType, model)
261+}
262+
263+func (w *AppWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
264+ return w.store.SetCompactMode(scope, enabled)
265+}
266+
267+func (w *AppWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
268+ return w.store.SetProviderAPIKey(scope, providerID, apiKey)
269+}
270+
271+func (w *AppWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
272+ return w.store.SetConfigField(scope, key, value)
273+}
274+
275+func (w *AppWorkspace) RemoveConfigField(scope config.Scope, key string) error {
276+ return w.store.RemoveConfigField(scope, key)
277+}
278+
279+func (w *AppWorkspace) ImportCopilot() (*oauth.Token, bool) {
280+ return w.store.ImportCopilot()
281+}
282+
283+func (w *AppWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
284+ return w.store.RefreshOAuthToken(ctx, scope, providerID)
285+}
286+
287+// -- Project lifecycle --
288+
289+func (w *AppWorkspace) ProjectNeedsInitialization() (bool, error) {
290+ return config.ProjectNeedsInitialization(w.store)
291+}
292+
293+func (w *AppWorkspace) MarkProjectInitialized() error {
294+ return config.MarkProjectInitialized(w.store)
295+}
296+
297+func (w *AppWorkspace) InitializePrompt() (string, error) {
298+ return agent.InitializePrompt(w.store)
299+}
300+
301+// -- MCP operations --
302+
303+func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
304+ return mcptools.GetStates()
305+}
306+
307+func (w *AppWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
308+ mcptools.RefreshPrompts(ctx, name)
309+}
310+
311+func (w *AppWorkspace) MCPRefreshResources(ctx context.Context, name string) {
312+ mcptools.RefreshResources(ctx, name)
313+}
314+
315+func (w *AppWorkspace) RefreshMCPTools(ctx context.Context, name string) {
316+ mcptools.RefreshTools(ctx, w.store, name)
317+}
318+
319+func (w *AppWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
320+ contents, err := mcptools.ReadResource(ctx, w.store, name, uri)
321+ if err != nil {
322+ return nil, err
323+ }
324+ result := make([]MCPResourceContents, len(contents))
325+ for i, c := range contents {
326+ result[i] = MCPResourceContents{
327+ URI: c.URI,
328+ MIMEType: c.MIMEType,
329+ Text: c.Text,
330+ Blob: c.Blob,
331+ }
332+ }
333+ return result, nil
334+}
335+
336+func (w *AppWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
337+ return commands.GetMCPPrompt(w.store, clientID, promptID, args)
338+}
339+
340+func (w *AppWorkspace) EnableDockerMCP(ctx context.Context) error {
341+ mcpConfig, err := w.store.PrepareDockerMCPConfig()
342+ if err != nil {
343+ return err
344+ }
345+
346+ if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, w.store); err != nil {
347+ disableErr := mcptools.DisableSingle(w.store, config.DockerMCPName)
348+ delete(w.store.Config().MCP, config.DockerMCPName)
349+ return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
350+ }
351+
352+ if err := w.store.PersistDockerMCPConfig(mcpConfig); err != nil {
353+ disableErr := mcptools.DisableSingle(w.store, config.DockerMCPName)
354+ delete(w.store.Config().MCP, config.DockerMCPName)
355+ return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
356+ }
357+
358+ return nil
359+}
360+
361+func (w *AppWorkspace) DisableDockerMCP() error {
362+ if err := mcptools.DisableSingle(w.store, config.DockerMCPName); err != nil {
363+ return fmt.Errorf("failed to disable docker MCP: %w", err)
364+ }
365+ return w.store.DisableDockerMCP()
366+}
367+
368+// -- Lifecycle --
369+
370+func (w *AppWorkspace) Subscribe(program *tea.Program) {
371+ w.app.Subscribe(program)
372+}
373+
374+func (w *AppWorkspace) Shutdown() {
375+ w.app.Shutdown()
376+}
377+
378+// App returns the underlying app.App instance.
379+func (w *AppWorkspace) App() *app.App {
380+ return w.app
381+}
382+
383+// Store returns the underlying config store.
384+func (w *AppWorkspace) Store() *config.ConfigStore {
385+ return w.store
386+}
387+
388+// Compile-time check that AppWorkspace implements Workspace.
389+var _ Workspace = (*AppWorkspace)(nil)
internal/workspace/client_workspace.gonew file+773
@@ -0,0 +1,773 @@
1+package workspace
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+ "strings"
8+ "sync"
9+ "time"
10+
11+ tea "charm.land/bubbletea/v2"
12+ "github.com/charmbracelet/crush/internal/agent/notify"
13+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
14+ "github.com/charmbracelet/crush/internal/client"
15+ "github.com/charmbracelet/crush/internal/config"
16+ "github.com/charmbracelet/crush/internal/history"
17+ "github.com/charmbracelet/crush/internal/log"
18+ "github.com/charmbracelet/crush/internal/lsp"
19+ "github.com/charmbracelet/crush/internal/message"
20+ "github.com/charmbracelet/crush/internal/oauth"
21+ "github.com/charmbracelet/crush/internal/permission"
22+ "github.com/charmbracelet/crush/internal/proto"
23+ "github.com/charmbracelet/crush/internal/pubsub"
24+ "github.com/charmbracelet/crush/internal/session"
25+ "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
26+)
27+
28+// ClientWorkspace implements the Workspace interface by delegating all
29+// operations to a remote server via the client SDK. It caches the
30+// proto.Workspace returned at creation time and refreshes it after
31+// config-mutating operations.
32+type ClientWorkspace struct {
33+ client *client.Client
34+
35+ mu sync.RWMutex
36+ ws proto.Workspace
37+}
38+
39+// NewClientWorkspace creates a new ClientWorkspace that proxies all
40+// operations through the given client SDK. The ws parameter is the
41+// proto.Workspace snapshot returned by the server at creation time.
42+func NewClientWorkspace(c *client.Client, ws proto.Workspace) *ClientWorkspace {
43+ if ws.Config != nil {
44+ ws.Config.SetupAgents()
45+ }
46+ return &ClientWorkspace{
47+ client: c,
48+ ws: ws,
49+ }
50+}
51+
52+// refreshWorkspace re-fetches the workspace from the server, updating
53+// the cached snapshot. Called after config-mutating operations.
54+func (w *ClientWorkspace) refreshWorkspace() {
55+ updated, err := w.client.GetWorkspace(context.Background(), w.ws.ID)
56+ if err != nil {
57+ slog.Error("Failed to refresh workspace", "error", err)
58+ return
59+ }
60+ if updated.Config != nil {
61+ updated.Config.SetupAgents()
62+ }
63+ w.mu.Lock()
64+ w.ws = *updated
65+ w.mu.Unlock()
66+}
67+
68+// cached returns a snapshot of the cached workspace.
69+func (w *ClientWorkspace) cached() proto.Workspace {
70+ w.mu.RLock()
71+ defer w.mu.RUnlock()
72+ return w.ws
73+}
74+
75+// workspaceID returns the cached workspace ID.
76+func (w *ClientWorkspace) workspaceID() string {
77+ return w.cached().ID
78+}
79+
80+// -- Sessions --
81+
82+func (w *ClientWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
83+ sess, err := w.client.CreateSession(ctx, w.workspaceID(), title)
84+ if err != nil {
85+ return session.Session{}, err
86+ }
87+ return protoToSession(*sess), nil
88+}
89+
90+func (w *ClientWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
91+ sess, err := w.client.GetSession(ctx, w.workspaceID(), sessionID)
92+ if err != nil {
93+ return session.Session{}, err
94+ }
95+ return protoToSession(*sess), nil
96+}
97+
98+func (w *ClientWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
99+ protoSessions, err := w.client.ListSessions(ctx, w.workspaceID())
100+ if err != nil {
101+ return nil, err
102+ }
103+ sessions := make([]session.Session, len(protoSessions))
104+ for i, s := range protoSessions {
105+ sessions[i] = protoToSession(s)
106+ }
107+ return sessions, nil
108+}
109+
110+func (w *ClientWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
111+ saved, err := w.client.SaveSession(ctx, w.workspaceID(), sessionToProto(sess))
112+ if err != nil {
113+ return session.Session{}, err
114+ }
115+ return protoToSession(*saved), nil
116+}
117+
118+func (w *ClientWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
119+ return w.client.DeleteSession(ctx, w.workspaceID(), sessionID)
120+}
121+
122+func (w *ClientWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
123+ return fmt.Sprintf("%s$$%s", messageID, toolCallID)
124+}
125+
126+func (w *ClientWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
127+ parts := strings.Split(sessionID, "$$")
128+ if len(parts) != 2 {
129+ return "", "", false
130+ }
131+ return parts[0], parts[1], true
132+}
133+
134+// -- Messages --
135+
136+func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
137+ msgs, err := w.client.ListMessages(ctx, w.workspaceID(), sessionID)
138+ if err != nil {
139+ return nil, err
140+ }
141+ return protoToMessages(msgs), nil
142+}
143+
144+func (w *ClientWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
145+ msgs, err := w.client.ListUserMessages(ctx, w.workspaceID(), sessionID)
146+ if err != nil {
147+ return nil, err
148+ }
149+ return protoToMessages(msgs), nil
150+}
151+
152+func (w *ClientWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
153+ msgs, err := w.client.ListAllUserMessages(ctx, w.workspaceID())
154+ if err != nil {
155+ return nil, err
156+ }
157+ return protoToMessages(msgs), nil
158+}
159+
160+// -- Agent --
161+
162+func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
163+ return w.client.SendMessage(ctx, w.workspaceID(), sessionID, prompt, attachments...)
164+}
165+
166+func (w *ClientWorkspace) AgentCancel(sessionID string) {
167+ _ = w.client.CancelAgentSession(context.Background(), w.workspaceID(), sessionID)
168+}
169+
170+func (w *ClientWorkspace) AgentIsBusy() bool {
171+ info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
172+ if err != nil {
173+ return false
174+ }
175+ return info.IsBusy
176+}
177+
178+func (w *ClientWorkspace) AgentIsSessionBusy(sessionID string) bool {
179+ info, err := w.client.GetAgentSessionInfo(context.Background(), w.workspaceID(), sessionID)
180+ if err != nil {
181+ return false
182+ }
183+ return info.IsBusy
184+}
185+
186+func (w *ClientWorkspace) AgentModel() AgentModel {
187+ info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
188+ if err != nil {
189+ return AgentModel{}
190+ }
191+ return AgentModel{
192+ CatwalkCfg: info.Model,
193+ ModelCfg: info.ModelCfg,
194+ }
195+}
196+
197+func (w *ClientWorkspace) AgentIsReady() bool {
198+ info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
199+ if err != nil {
200+ return false
201+ }
202+ return info.IsReady
203+}
204+
205+func (w *ClientWorkspace) AgentQueuedPrompts(sessionID string) int {
206+ count, err := w.client.GetAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
207+ if err != nil {
208+ return 0
209+ }
210+ return count
211+}
212+
213+func (w *ClientWorkspace) AgentQueuedPromptsList(sessionID string) []string {
214+ prompts, err := w.client.GetAgentSessionQueuedPromptsList(context.Background(), w.workspaceID(), sessionID)
215+ if err != nil {
216+ return nil
217+ }
218+ return prompts
219+}
220+
221+func (w *ClientWorkspace) AgentClearQueue(sessionID string) {
222+ _ = w.client.ClearAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
223+}
224+
225+func (w *ClientWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
226+ return w.client.AgentSummarizeSession(ctx, w.workspaceID(), sessionID)
227+}
228+
229+func (w *ClientWorkspace) UpdateAgentModel(ctx context.Context) error {
230+ return w.client.UpdateAgent(ctx, w.workspaceID())
231+}
232+
233+func (w *ClientWorkspace) InitCoderAgent(ctx context.Context) error {
234+ return w.client.InitiateAgentProcessing(ctx, w.workspaceID())
235+}
236+
237+func (w *ClientWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
238+ model, err := w.client.GetDefaultSmallModel(context.Background(), w.workspaceID(), providerID)
239+ if err != nil {
240+ return config.SelectedModel{}
241+ }
242+ return *model
243+}
244+
245+// -- Permissions --
246+
247+func (w *ClientWorkspace) PermissionGrant(perm permission.PermissionRequest) {
248+ _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
249+ Permission: proto.PermissionRequest{
250+ ID: perm.ID,
251+ SessionID: perm.SessionID,
252+ ToolCallID: perm.ToolCallID,
253+ ToolName: perm.ToolName,
254+ Description: perm.Description,
255+ Action: perm.Action,
256+ Path: perm.Path,
257+ Params: perm.Params,
258+ },
259+ Action: proto.PermissionAllowForSession,
260+ })
261+}
262+
263+func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
264+ _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
265+ Permission: proto.PermissionRequest{
266+ ID: perm.ID,
267+ SessionID: perm.SessionID,
268+ ToolCallID: perm.ToolCallID,
269+ ToolName: perm.ToolName,
270+ Description: perm.Description,
271+ Action: perm.Action,
272+ Path: perm.Path,
273+ Params: perm.Params,
274+ },
275+ Action: proto.PermissionAllow,
276+ })
277+}
278+
279+func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) {
280+ _ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
281+ Permission: proto.PermissionRequest{
282+ ID: perm.ID,
283+ SessionID: perm.SessionID,
284+ ToolCallID: perm.ToolCallID,
285+ ToolName: perm.ToolName,
286+ Description: perm.Description,
287+ Action: perm.Action,
288+ Path: perm.Path,
289+ Params: perm.Params,
290+ },
291+ Action: proto.PermissionDeny,
292+ })
293+}
294+
295+func (w *ClientWorkspace) PermissionSkipRequests() bool {
296+ skip, err := w.client.GetPermissionsSkipRequests(context.Background(), w.workspaceID())
297+ if err != nil {
298+ return false
299+ }
300+ return skip
301+}
302+
303+func (w *ClientWorkspace) PermissionSetSkipRequests(skip bool) {
304+ _ = w.client.SetPermissionsSkipRequests(context.Background(), w.workspaceID(), skip)
305+}
306+
307+// -- FileTracker --
308+
309+func (w *ClientWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
310+ _ = w.client.FileTrackerRecordRead(ctx, w.workspaceID(), sessionID, path)
311+}
312+
313+func (w *ClientWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
314+ t, err := w.client.FileTrackerLastReadTime(ctx, w.workspaceID(), sessionID, path)
315+ if err != nil {
316+ return time.Time{}
317+ }
318+ return t
319+}
320+
321+func (w *ClientWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
322+ return w.client.FileTrackerListReadFiles(ctx, w.workspaceID(), sessionID)
323+}
324+
325+// -- History --
326+
327+func (w *ClientWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
328+ files, err := w.client.ListSessionHistoryFiles(ctx, w.workspaceID(), sessionID)
329+ if err != nil {
330+ return nil, err
331+ }
332+ return protoToFiles(files), nil
333+}
334+
335+// -- LSP --
336+
337+func (w *ClientWorkspace) LSPStart(ctx context.Context, path string) {
338+ _ = w.client.LSPStart(ctx, w.workspaceID(), path)
339+}
340+
341+func (w *ClientWorkspace) LSPStopAll(ctx context.Context) {
342+ _ = w.client.LSPStopAll(ctx, w.workspaceID())
343+}
344+
345+func (w *ClientWorkspace) LSPGetStates() map[string]LSPClientInfo {
346+ states, err := w.client.GetLSPs(context.Background(), w.workspaceID())
347+ if err != nil {
348+ return nil
349+ }
350+ result := make(map[string]LSPClientInfo, len(states))
351+ for k, v := range states {
352+ result[k] = LSPClientInfo{
353+ Name: v.Name,
354+ State: v.State,
355+ Error: v.Error,
356+ DiagnosticCount: v.DiagnosticCount,
357+ ConnectedAt: v.ConnectedAt,
358+ }
359+ }
360+ return result
361+}
362+
363+func (w *ClientWorkspace) LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts {
364+ diags, err := w.client.GetLSPDiagnostics(context.Background(), w.workspaceID(), name)
365+ if err != nil {
366+ return lsp.DiagnosticCounts{}
367+ }
368+ var counts lsp.DiagnosticCounts
369+ for _, fileDiags := range diags {
370+ for _, d := range fileDiags {
371+ switch d.Severity {
372+ case protocol.SeverityError:
373+ counts.Error++
374+ case protocol.SeverityWarning:
375+ counts.Warning++
376+ case protocol.SeverityInformation:
377+ counts.Information++
378+ case protocol.SeverityHint:
379+ counts.Hint++
380+ }
381+ }
382+ }
383+ return counts
384+}
385+
386+// -- Config (read-only) --
387+
388+func (w *ClientWorkspace) Config() *config.Config {
389+ return w.cached().Config
390+}
391+
392+func (w *ClientWorkspace) WorkingDir() string {
393+ return w.cached().Path
394+}
395+
396+func (w *ClientWorkspace) Resolver() config.VariableResolver {
397+ return config.IdentityResolver()
398+}
399+
400+// -- Config mutations --
401+
402+func (w *ClientWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
403+ err := w.client.UpdatePreferredModel(context.Background(), w.workspaceID(), scope, modelType, model)
404+ if err == nil {
405+ w.refreshWorkspace()
406+ }
407+ return err
408+}
409+
410+func (w *ClientWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
411+ err := w.client.SetCompactMode(context.Background(), w.workspaceID(), scope, enabled)
412+ if err == nil {
413+ w.refreshWorkspace()
414+ }
415+ return err
416+}
417+
418+func (w *ClientWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
419+ err := w.client.SetProviderAPIKey(context.Background(), w.workspaceID(), scope, providerID, apiKey)
420+ if err == nil {
421+ w.refreshWorkspace()
422+ }
423+ return err
424+}
425+
426+func (w *ClientWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
427+ err := w.client.SetConfigField(context.Background(), w.workspaceID(), scope, key, value)
428+ if err == nil {
429+ w.refreshWorkspace()
430+ }
431+ return err
432+}
433+
434+func (w *ClientWorkspace) RemoveConfigField(scope config.Scope, key string) error {
435+ err := w.client.RemoveConfigField(context.Background(), w.workspaceID(), scope, key)
436+ if err == nil {
437+ w.refreshWorkspace()
438+ }
439+ return err
440+}
441+
442+func (w *ClientWorkspace) ImportCopilot() (*oauth.Token, bool) {
443+ token, ok, err := w.client.ImportCopilot(context.Background(), w.workspaceID())
444+ if err != nil {
445+ return nil, false
446+ }
447+ if ok {
448+ w.refreshWorkspace()
449+ }
450+ return token, ok
451+}
452+
453+func (w *ClientWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
454+ err := w.client.RefreshOAuthToken(ctx, w.workspaceID(), scope, providerID)
455+ if err == nil {
456+ w.refreshWorkspace()
457+ }
458+ return err
459+}
460+
461+// -- Project lifecycle --
462+
463+func (w *ClientWorkspace) ProjectNeedsInitialization() (bool, error) {
464+ return w.client.ProjectNeedsInitialization(context.Background(), w.workspaceID())
465+}
466+
467+func (w *ClientWorkspace) MarkProjectInitialized() error {
468+ return w.client.MarkProjectInitialized(context.Background(), w.workspaceID())
469+}
470+
471+func (w *ClientWorkspace) InitializePrompt() (string, error) {
472+ return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
473+}
474+
475+// -- MCP operations --
476+
477+func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
478+ states, err := w.client.MCPGetStates(context.Background(), w.workspaceID())
479+ if err != nil {
480+ return nil
481+ }
482+ result := make(map[string]mcp.ClientInfo, len(states))
483+ for k, v := range states {
484+ result[k] = mcp.ClientInfo{
485+ Name: v.Name,
486+ State: mcp.State(v.State),
487+ Error: v.Error,
488+ Counts: mcp.Counts{
489+ Tools: v.ToolCount,
490+ Prompts: v.PromptCount,
491+ Resources: v.ResourceCount,
492+ },
493+ ConnectedAt: v.ConnectedAt,
494+ }
495+ }
496+ return result
497+}
498+
499+func (w *ClientWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
500+ _ = w.client.MCPRefreshPrompts(ctx, w.workspaceID(), name)
501+}
502+
503+func (w *ClientWorkspace) MCPRefreshResources(ctx context.Context, name string) {
504+ _ = w.client.MCPRefreshResources(ctx, w.workspaceID(), name)
505+}
506+
507+func (w *ClientWorkspace) RefreshMCPTools(ctx context.Context, name string) {
508+ _ = w.client.RefreshMCPTools(ctx, w.workspaceID(), name)
509+}
510+
511+func (w *ClientWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
512+ contents, err := w.client.ReadMCPResource(ctx, w.workspaceID(), name, uri)
513+ if err != nil {
514+ return nil, err
515+ }
516+ result := make([]MCPResourceContents, len(contents))
517+ for i, c := range contents {
518+ result[i] = MCPResourceContents{
519+ URI: c.URI,
520+ MIMEType: c.MIMEType,
521+ Text: c.Text,
522+ Blob: c.Blob,
523+ }
524+ }
525+ return result, nil
526+}
527+
528+func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
529+ return w.client.GetMCPPrompt(context.Background(), w.workspaceID(), clientID, promptID, args)
530+}
531+
532+func (w *ClientWorkspace) EnableDockerMCP(ctx context.Context) error {
533+ return w.client.EnableDockerMCP(ctx, w.workspaceID())
534+}
535+
536+func (w *ClientWorkspace) DisableDockerMCP() error {
537+ return w.client.DisableDockerMCP(context.Background(), w.workspaceID())
538+}
539+
540+// -- Lifecycle --
541+
542+func (w *ClientWorkspace) Subscribe(program *tea.Program) {
543+ defer log.RecoverPanic("ClientWorkspace.Subscribe", func() {
544+ slog.Info("TUI subscription panic: attempting graceful shutdown")
545+ program.Quit()
546+ })
547+
548+ evc, err := w.client.SubscribeEvents(context.Background(), w.workspaceID())
549+ if err != nil {
550+ slog.Error("Failed to subscribe to events", "error", err)
551+ return
552+ }
553+
554+ for ev := range evc {
555+ translated := translateEvent(ev)
556+ if translated != nil {
557+ program.Send(translated)
558+ }
559+ }
560+}
561+
562+func (w *ClientWorkspace) Shutdown() {
563+ _ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
564+}
565+
566+// translateEvent converts proto-typed SSE events into the domain types
567+// that the TUI's Update() method expects.
568+func translateEvent(ev any) tea.Msg {
569+ switch e := ev.(type) {
570+ case pubsub.Event[proto.LSPEvent]:
571+ return pubsub.Event[LSPEvent]{
572+ Type: e.Type,
573+ Payload: LSPEvent{
574+ Type: LSPEventType(e.Payload.Type),
575+ Name: e.Payload.Name,
576+ State: e.Payload.State,
577+ Error: e.Payload.Error,
578+ DiagnosticCount: e.Payload.DiagnosticCount,
579+ },
580+ }
581+ case pubsub.Event[proto.MCPEvent]:
582+ return pubsub.Event[mcp.Event]{
583+ Type: e.Type,
584+ Payload: mcp.Event{
585+ Type: protoToMCPEventType(e.Payload.Type),
586+ Name: e.Payload.Name,
587+ State: mcp.State(e.Payload.State),
588+ Error: e.Payload.Error,
589+ Counts: mcp.Counts{
590+ Tools: e.Payload.ToolCount,
591+ Prompts: e.Payload.PromptCount,
592+ Resources: e.Payload.ResourceCount,
593+ },
594+ },
595+ }
596+ case pubsub.Event[proto.PermissionRequest]:
597+ return pubsub.Event[permission.PermissionRequest]{
598+ Type: e.Type,
599+ Payload: permission.PermissionRequest{
600+ ID: e.Payload.ID,
601+ SessionID: e.Payload.SessionID,
602+ ToolCallID: e.Payload.ToolCallID,
603+ ToolName: e.Payload.ToolName,
604+ Description: e.Payload.Description,
605+ Action: e.Payload.Action,
606+ Path: e.Payload.Path,
607+ Params: e.Payload.Params,
608+ },
609+ }
610+ case pubsub.Event[proto.PermissionNotification]:
611+ return pubsub.Event[permission.PermissionNotification]{
612+ Type: e.Type,
613+ Payload: permission.PermissionNotification{
614+ ToolCallID: e.Payload.ToolCallID,
615+ Granted: e.Payload.Granted,
616+ Denied: e.Payload.Denied,
617+ },
618+ }
619+ case pubsub.Event[proto.Message]:
620+ return pubsub.Event[message.Message]{
621+ Type: e.Type,
622+ Payload: protoToMessage(e.Payload),
623+ }
624+ case pubsub.Event[proto.Session]:
625+ return pubsub.Event[session.Session]{
626+ Type: e.Type,
627+ Payload: protoToSession(e.Payload),
628+ }
629+ case pubsub.Event[proto.File]:
630+ return pubsub.Event[history.File]{
631+ Type: e.Type,
632+ Payload: protoToFile(e.Payload),
633+ }
634+ case pubsub.Event[proto.AgentEvent]:
635+ return pubsub.Event[notify.Notification]{
636+ Type: e.Type,
637+ Payload: notify.Notification{
638+ SessionID: e.Payload.SessionID,
639+ SessionTitle: e.Payload.SessionTitle,
640+ Type: notify.Type(e.Payload.Type),
641+ },
642+ }
643+ default:
644+ slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
645+ return nil
646+ }
647+}
648+
649+func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
650+ switch t {
651+ case proto.MCPEventStateChanged:
652+ return mcp.EventStateChanged
653+ case proto.MCPEventToolsListChanged:
654+ return mcp.EventToolsListChanged
655+ case proto.MCPEventPromptsListChanged:
656+ return mcp.EventPromptsListChanged
657+ case proto.MCPEventResourcesListChanged:
658+ return mcp.EventResourcesListChanged
659+ default:
660+ return mcp.EventStateChanged
661+ }
662+}
663+
664+func protoToSession(s proto.Session) session.Session {
665+ return session.Session{
666+ ID: s.ID,
667+ ParentSessionID: s.ParentSessionID,
668+ Title: s.Title,
669+ SummaryMessageID: s.SummaryMessageID,
670+ MessageCount: s.MessageCount,
671+ PromptTokens: s.PromptTokens,
672+ CompletionTokens: s.CompletionTokens,
673+ Cost: s.Cost,
674+ CreatedAt: s.CreatedAt,
675+ UpdatedAt: s.UpdatedAt,
676+ }
677+}
678+
679+func protoToFile(f proto.File) history.File {
680+ return history.File{
681+ ID: f.ID,
682+ SessionID: f.SessionID,
683+ Path: f.Path,
684+ Content: f.Content,
685+ Version: f.Version,
686+ CreatedAt: f.CreatedAt,
687+ UpdatedAt: f.UpdatedAt,
688+ }
689+}
690+
691+func protoToMessage(m proto.Message) message.Message {
692+ msg := message.Message{
693+ ID: m.ID,
694+ SessionID: m.SessionID,
695+ Role: message.MessageRole(m.Role),
696+ Model: m.Model,
697+ Provider: m.Provider,
698+ CreatedAt: m.CreatedAt,
699+ UpdatedAt: m.UpdatedAt,
700+ }
701+
702+ for _, p := range m.Parts {
703+ switch v := p.(type) {
704+ case proto.TextContent:
705+ msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
706+ case proto.ReasoningContent:
707+ msg.Parts = append(msg.Parts, message.ReasoningContent{
708+ Thinking: v.Thinking,
709+ Signature: v.Signature,
710+ StartedAt: v.StartedAt,
711+ FinishedAt: v.FinishedAt,
712+ })
713+ case proto.ToolCall:
714+ msg.Parts = append(msg.Parts, message.ToolCall{
715+ ID: v.ID,
716+ Name: v.Name,
717+ Input: v.Input,
718+ Finished: v.Finished,
719+ })
720+ case proto.ToolResult:
721+ msg.Parts = append(msg.Parts, message.ToolResult{
722+ ToolCallID: v.ToolCallID,
723+ Name: v.Name,
724+ Content: v.Content,
725+ IsError: v.IsError,
726+ })
727+ case proto.Finish:
728+ msg.Parts = append(msg.Parts, message.Finish{
729+ Reason: message.FinishReason(v.Reason),
730+ Time: v.Time,
731+ Message: v.Message,
732+ Details: v.Details,
733+ })
734+ case proto.ImageURLContent:
735+ msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
736+ case proto.BinaryContent:
737+ msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
738+ }
739+ }
740+
741+ return msg
742+}
743+
744+func protoToMessages(msgs []proto.Message) []message.Message {
745+ out := make([]message.Message, len(msgs))
746+ for i, m := range msgs {
747+ out[i] = protoToMessage(m)
748+ }
749+ return out
750+}
751+
752+func protoToFiles(files []proto.File) []history.File {
753+ out := make([]history.File, len(files))
754+ for i, f := range files {
755+ out[i] = protoToFile(f)
756+ }
757+ return out
758+}
759+
760+func sessionToProto(s session.Session) proto.Session {
761+ return proto.Session{
762+ ID: s.ID,
763+ ParentSessionID: s.ParentSessionID,
764+ Title: s.Title,
765+ SummaryMessageID: s.SummaryMessageID,
766+ MessageCount: s.MessageCount,
767+ PromptTokens: s.PromptTokens,
768+ CompletionTokens: s.CompletionTokens,
769+ Cost: s.Cost,
770+ CreatedAt: s.CreatedAt,
771+ UpdatedAt: s.UpdatedAt,
772+ }
773+}
internal/workspace/workspace.gonew file+152
@@ -0,0 +1,152 @@
1+// Package workspace defines the Workspace interface used by all
2+// frontends (TUI, CLI) to interact with a running workspace. Two
3+// implementations exist: one wrapping a local app.App instance and one
4+// wrapping the HTTP client SDK.
5+package workspace
6+
7+import (
8+ "context"
9+ "time"
10+
11+ tea "charm.land/bubbletea/v2"
12+ "charm.land/catwalk/pkg/catwalk"
13+ mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/history"
16+ "github.com/charmbracelet/crush/internal/lsp"
17+ "github.com/charmbracelet/crush/internal/message"
18+ "github.com/charmbracelet/crush/internal/oauth"
19+ "github.com/charmbracelet/crush/internal/permission"
20+ "github.com/charmbracelet/crush/internal/session"
21+)
22+
23+// LSPClientInfo holds information about an LSP client's state. This is
24+// the frontend-facing type; implementations translate from the
25+// underlying app or proto representation.
26+type LSPClientInfo struct {
27+ Name string
28+ State lsp.ServerState
29+ Error error
30+ DiagnosticCount int
31+ ConnectedAt time.Time
32+}
33+
34+// LSPEventType represents the type of LSP event.
35+type LSPEventType string
36+
37+const (
38+ LSPEventStateChanged LSPEventType = "state_changed"
39+ LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed"
40+)
41+
42+// LSPEvent represents an LSP event forwarded to the TUI.
43+type LSPEvent struct {
44+ Type LSPEventType
45+ Name string
46+ State lsp.ServerState
47+ Error error
48+ DiagnosticCount int
49+}
50+
51+// AgentModel holds the model information exposed to the UI.
52+type AgentModel struct {
53+ CatwalkCfg catwalk.Model
54+ ModelCfg config.SelectedModel
55+}
56+
57+// Workspace is the main abstraction consumed by the TUI and CLI. It
58+// groups every operation a frontend needs to perform against a running
59+// workspace, regardless of whether the workspace is in-process or
60+// remote.
61+type Workspace interface {
62+ // Sessions
63+ CreateSession(ctx context.Context, title string) (session.Session, error)
64+ GetSession(ctx context.Context, sessionID string) (session.Session, error)
65+ ListSessions(ctx context.Context) ([]session.Session, error)
66+ SaveSession(ctx context.Context, sess session.Session) (session.Session, error)
67+ DeleteSession(ctx context.Context, sessionID string) error
68+ CreateAgentToolSessionID(messageID, toolCallID string) string
69+ ParseAgentToolSessionID(sessionID string) (messageID string, toolCallID string, ok bool)
70+
71+ // Messages
72+ ListMessages(ctx context.Context, sessionID string) ([]message.Message, error)
73+ ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error)
74+ ListAllUserMessages(ctx context.Context) ([]message.Message, error)
75+
76+ // Agent
77+ AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error
78+ AgentCancel(sessionID string)
79+ AgentIsBusy() bool
80+ AgentIsSessionBusy(sessionID string) bool
81+ AgentModel() AgentModel
82+ AgentIsReady() bool
83+ AgentQueuedPrompts(sessionID string) int
84+ AgentQueuedPromptsList(sessionID string) []string
85+ AgentClearQueue(sessionID string)
86+ AgentSummarize(ctx context.Context, sessionID string) error
87+ UpdateAgentModel(ctx context.Context) error
88+ InitCoderAgent(ctx context.Context) error
89+ GetDefaultSmallModel(providerID string) config.SelectedModel
90+
91+ // Permissions
92+ PermissionGrant(perm permission.PermissionRequest)
93+ PermissionGrantPersistent(perm permission.PermissionRequest)
94+ PermissionDeny(perm permission.PermissionRequest)
95+ PermissionSkipRequests() bool
96+ PermissionSetSkipRequests(skip bool)
97+
98+ // FileTracker
99+ FileTrackerRecordRead(ctx context.Context, sessionID, path string)
100+ FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time
101+ FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error)
102+
103+ // History
104+ ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error)
105+
106+ // LSP
107+ LSPStart(ctx context.Context, path string)
108+ LSPStopAll(ctx context.Context)
109+ LSPGetStates() map[string]LSPClientInfo
110+ LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts
111+
112+ // Config (read-only data)
113+ Config() *config.Config
114+ WorkingDir() string
115+ Resolver() config.VariableResolver
116+
117+ // Config mutations (proxied to server in client mode)
118+ UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error
119+ SetCompactMode(scope config.Scope, enabled bool) error
120+ SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error
121+ SetConfigField(scope config.Scope, key string, value any) error
122+ RemoveConfigField(scope config.Scope, key string) error
123+ ImportCopilot() (*oauth.Token, bool)
124+ RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error
125+
126+ // Project lifecycle
127+ ProjectNeedsInitialization() (bool, error)
128+ MarkProjectInitialized() error
129+ InitializePrompt() (string, error)
130+
131+ // MCP operations (server-side in client mode)
132+ MCPGetStates() map[string]mcptools.ClientInfo
133+ MCPRefreshPrompts(ctx context.Context, name string)
134+ MCPRefreshResources(ctx context.Context, name string)
135+ RefreshMCPTools(ctx context.Context, name string)
136+ ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error)
137+ GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error)
138+ EnableDockerMCP(ctx context.Context) error
139+ DisableDockerMCP() error
140+
141+ // Events
142+ Subscribe(program *tea.Program)
143+ Shutdown()
144+}
145+
146+// MCPResourceContents holds the contents of an MCP resource.
147+type MCPResourceContents struct {
148+ URI string `json:"uri"`
149+ MIMEType string `json:"mime_type,omitempty"`
150+ Text string `json:"text,omitempty"`
151+ Blob []byte `json:"blob,omitempty"`
152+}
main.go+10
@@ -1,3 +1,13 @@
1+// Package main is the entry point for the Crush CLI.
2+//
3+// @title Crush API
4+// @version 1.0
5+// @description Crush is a terminal-based AI coding assistant. This API is served over a Unix socket (or Windows named pipe) and provides programmatic access to workspaces, sessions, agents, LSP, MCP, and more.
6+// @contact.name Charm
7+// @contact.url https://charm.sh
8+// @license.name MIT
9+// @license.url https://github.com/charmbracelet/crush/blob/main/LICENSE
10+// @BasePath /v1
1package main
11package main
2
12
3import (
13import (