on main help sign in
Merge pull request #2455 from charmbracelet/server-client-2

Feat/Refactor: Server Client architecture and API

commit28f2087e22c7fb300d206cd37f3fb036162bbd48 authorAyman Bagabas <ayman.bagabas@gmail.com> dateThu 2 Apr 2026, 15:51 (7 months ago) parente3df84eed71633
76 files changed+18070−304
Taskfile.yaml+13 go.mod+14−1 go.sum+30 internal/app/app.go+15−1 internal/backend/agent.go+144 internal/backend/backend.go+204 internal/backend/config.go+214 internal/backend/events.go+107 internal/backend/filetracker.go+37 internal/backend/permission.go+59 internal/backend/session.go+126 internal/backend/util.go+22 internal/client/client.go+196 internal/client/config.go+278 internal/client/dial_other.go+14 internal/client/dial_windows.go+15 internal/client/proto.go+750 internal/cmd/login.go+28−17 internal/cmd/root.go+331−37 internal/cmd/root_other.go+16 internal/cmd/root_windows.go+18 internal/cmd/run.go+436−24 internal/cmd/server.go+99 internal/cmd/server_other.go+13 internal/cmd/server_windows.go+10 internal/config/config.go+1−2 internal/config/load.go+28−7 internal/config/resolve.go+14 internal/config/scope.go+18 internal/config/store.go+32−6 internal/config/store_test.go+152 internal/log/log.go+20−4 internal/proto/agent.go+75 internal/proto/history.go+12 internal/proto/mcp.go+172 internal/proto/message.go+653 internal/proto/permission.go+141 internal/proto/proto.go+200 internal/proto/requests.go+92 internal/proto/server.go+6 internal/proto/session.go+15 internal/proto/tools.go+250 internal/proto/version.go+9 internal/pubsub/events.go+31−5 internal/server/config.go+467 internal/server/events.go+214 internal/server/logging.go+51 internal/server/net_other.go+11 internal/server/net_windows.go+24 internal/server/proto.go+969 internal/server/server.go+234 internal/swagger/docs.go+3589 internal/swagger/swagger.json+3564 internal/swagger/swagger.yaml+2361 internal/ui/common/common.go+7−12 internal/ui/dialog/api_key_input.go+2−4 internal/ui/dialog/filepicker.go+1−1 internal/ui/dialog/models.go+1−1 internal/ui/dialog/oauth.go+1−3 internal/ui/dialog/sessions.go+5−5 internal/ui/model/header.go+15−16 internal/ui/model/history.go+2−2 internal/ui/model/landing.go+5−5 internal/ui/model/lsp.go+8−10 internal/ui/model/onboarding.go+7−8 internal/ui/model/pills.go+2−2 internal/ui/model/session.go+4−4 internal/ui/model/sidebar.go+7−3 internal/ui/model/ui.go+71−99 internal/ui/model/ui_test.go+9−21 internal/ui/util/util.go−2 internal/version/version.go+5−2 internal/workspace/app_workspace.go+389 internal/workspace/client_workspace.go+773 internal/workspace/workspace.go+152 main.go+10
Taskfile.yaml+13
@@ -196,6 +196,19 @@ tasks:
196196 - go get charm.land/catwalk@latest
197197 - go mod tidy
198198
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+
199212 sqlc:
200213 desc: Generate code using SQLC
201214 cmds:
go.mod+14−1
@@ -14,6 +14,7 @@ require (
1414 charm.land/x/vcr v0.1.1
1515 github.com/JohannesKaufmann/html-to-markdown v1.6.0
1616 github.com/MakeNowJust/heredoc v1.0.0
17+ github.com/Microsoft/go-winio v0.6.2
1718 github.com/PuerkitoBio/goquery v1.12.0
1819 github.com/alecthomas/chroma/v2 v2.23.1
1920 github.com/atotto/clipboard v0.1.4
@@ -59,12 +60,15 @@ require (
5960 github.com/sourcegraph/jsonrpc2 v0.2.1
6061 github.com/spf13/cobra v1.10.2
6162 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
6265 github.com/tidwall/gjson v1.18.0
6366 github.com/tidwall/sjson v1.2.5
6467 github.com/zeebo/xxh3 v1.1.0
6568 go.uber.org/goleak v1.3.0
6669 golang.org/x/net v0.52.0
6770 golang.org/x/sync v0.20.0
71+ golang.org/x/sys v0.42.0
6872 golang.org/x/text v0.35.0
6973 gopkg.in/natefinch/lumberjack.v2 v2.2.1
7074 gopkg.in/yaml.v3 v3.0.1
@@ -81,6 +85,7 @@ require (
8185 git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
8286 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
8387 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
88+ github.com/KyleBanks/depth v1.2.1 // indirect
8489 github.com/andybalholm/cascadia v1.3.3 // indirect
8590 github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
8691 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
@@ -119,6 +124,10 @@ require (
119124 github.com/go-logr/logr v1.4.3 // indirect
120125 github.com/go-logr/stdr v1.2.2 // indirect
121126 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
122131 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
123132 github.com/goccy/go-json v0.10.5 // indirect
124133 github.com/goccy/go-yaml v1.19.2 // indirect
@@ -135,6 +144,7 @@ require (
135144 github.com/inconshreveable/mousetrap v1.1.0 // indirect
136145 github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
137146 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
147+ github.com/josharian/intern v1.0.0 // indirect
138148 github.com/kaptinlin/go-i18n v0.2.12 // indirect
139149 github.com/kaptinlin/jsonpointer v0.4.17 // indirect
140150 github.com/kaptinlin/jsonschema v0.7.6 // indirect
@@ -166,6 +176,7 @@ require (
166176 github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
167177 github.com/sethvargo/go-retry v0.3.0 // indirect
168178 github.com/spf13/pflag v1.0.9 // indirect
179+ github.com/swaggo/files/v2 v2.0.0 // indirect
169180 github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
170181 github.com/tidwall/match v1.1.1 // indirect
171182 github.com/tidwall/pretty v1.2.1 // indirect
@@ -187,10 +198,11 @@ require (
187198 golang.org/x/crypto v0.49.0 // indirect
188199 golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
189200 golang.org/x/image v0.38.0 // indirect
201+ golang.org/x/mod v0.34.0 // indirect
190202 golang.org/x/oauth2 v0.36.0 // indirect
191− golang.org/x/sys v0.42.0 // indirect
192203 golang.org/x/term v0.41.0 // indirect
193204 golang.org/x/time v0.15.0 // indirect
205+ golang.org/x/tools v0.43.0 // indirect
194206 google.golang.org/api v0.271.0 // indirect
195207 google.golang.org/genai v1.51.0 // indirect
196208 google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
@@ -199,6 +211,7 @@ require (
199211 gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
200212 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
201213 gopkg.in/warnings.v0 v0.1.2 // indirect
214+ gopkg.in/yaml.v2 v2.4.0 // indirect
202215 modernc.org/libc v1.70.0 // indirect
203216 modernc.org/mathutil v1.7.1 // indirect
204217 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
3636github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
3737github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
3838github.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=
3941github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
4042github.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=
4145github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
4246github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
4347github.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
139143github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
140144github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
141145github.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=
142147github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
143148github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
144149github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
@@ -190,6 +195,16 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
190195github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
191196github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
192197github.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=
193208github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
194209github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
195210github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
@@ -243,6 +258,7 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
243258github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
244259github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4=
245260github.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=
246262github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
247263github.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4=
248264github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
@@ -269,6 +285,9 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
269285github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
270286github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
271287github.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=
272291github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
273292github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
274293github.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
303322github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
304323github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
305324github.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=
306326github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
307327github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
308328github.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
365385github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
366386github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
367387github.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=
368389github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
369390github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
370391github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
371392github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
372393github.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=
373400github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
374401github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
375402github.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
536563google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
537564google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
538565gopkg.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=
539567gopkg.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=
540569gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
541570gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
542571gopkg.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=
551580gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
552581gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
553582gopkg.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=
554584gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
555585gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
556586modernc.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
8181 messages := message.NewService(q)
8282 files := history.NewService(q, conn)
8383 cfg := store.Config()
84− skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
84+ skipPermissionsRequests := store.Overrides().SkipPermissionRequests
8585 var allowedTools []string
8686 if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
8787 allowedTools = cfg.Permissions.AllowedTools
@@ -152,6 +152,20 @@ func (app *App) Store() *config.ConfigStore {
152152 return app.config
153153}
154154
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+
155169// AgentNotifications returns the broker for agent notification events.
156170func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] {
157171 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 (
1010 "charm.land/lipgloss/v2"
1111 "github.com/atotto/clipboard"
1212 hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
13+ "github.com/charmbracelet/crush/internal/client"
1314 "github.com/charmbracelet/crush/internal/config"
1415 "github.com/charmbracelet/crush/internal/oauth"
1516 "github.com/charmbracelet/crush/internal/oauth/copilot"
1617 "github.com/charmbracelet/crush/internal/oauth/hyper"
18+ "github.com/charmbracelet/x/ansi"
1719 "github.com/pkg/browser"
1820 "github.com/spf13/cobra"
1921)
@@ -40,11 +42,17 @@ crush login copilot
4042 },
4143 Args: cobra.MaximumNArgs(1),
4244 RunE: func(cmd *cobra.Command, args []string) error {
43− app, err := setupAppWithProgressBar(cmd)
45+ c, ws, cleanup, err := connectToServer(cmd)
4446 if err != nil {
4547 return err
4648 }
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+ }
4856
4957 provider := "hyper"
5058 if len(args) > 0 {
@@ -52,16 +60,16 @@ crush login copilot
5260 }
5361 switch provider {
5462 case "hyper":
55− return loginHyper(app.Store())
63+ return loginHyper(c, ws.ID)
5664 case "copilot", "github", "github-copilot":
57− return loginCopilot(app.Store())
65+ return loginCopilot(cmd.Context(), c, ws.ID)
5866 default:
5967 return fmt.Errorf("unknown platform: %s", args[0])
6068 }
6169 },
6270}
6371
64−func loginHyper(cfg *config.ConfigStore) error {
72+func loginHyper(c *client.Client, wsID string) error {
6573 if !hyperp.Enabled() {
6674 return fmt.Errorf("hyper not enabled")
6775 }
@@ -112,8 +120,8 @@ func loginHyper(cfg *config.ConfigStore) error {
112120 }
113121
114122 if err := cmp.Or(
115− cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
116− cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.oauth", token),
123+ c.SetConfigField(ctx, wsID, config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
124+ c.SetConfigField(ctx, wsID, config.ScopeGlobal, "providers.hyper.oauth", token),
117125 ); err != nil {
118126 return err
119127 }
@@ -123,12 +131,15 @@ func loginHyper(cfg *config.ConfigStore) error {
123131 return nil
124132}
125133
126−func loginCopilot(cfg *config.ConfigStore) error {
127− ctx := getLoginContext()
134+func loginCopilot(ctx context.Context, c *client.Client, wsID string) error {
135+ loginCtx := getLoginContext()
128136
129− if cfg.HasConfigField(config.ScopeGlobal, "providers.copilot.oauth") {
130− fmt.Println("You are already logged in to GitHub Copilot.")
131− return nil
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 {
140+ fmt.Println("You are already logged in to GitHub Copilot.")
141+ return nil
142+ }
132143 }
133144
134145 diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
@@ -138,14 +149,14 @@ func loginCopilot(cfg *config.ConfigStore) error {
138149 case hasDiskToken:
139150 fmt.Println("Found existing GitHub Copilot token on disk. Using it to authenticate...")
140151
141− t, err := copilot.RefreshToken(ctx, diskToken)
152+ t, err := copilot.RefreshToken(loginCtx, diskToken)
142153 if err != nil {
143154 return fmt.Errorf("unable to refresh token from disk: %w", err)
144155 }
145156 token = t
146157 default:
147158 fmt.Println("Requesting device code from GitHub...")
148− dc, err := copilot.RequestDeviceCode(ctx)
159+ dc, err := copilot.RequestDeviceCode(loginCtx)
149160 if err != nil {
150161 return err
151162 }
@@ -159,7 +170,7 @@ func loginCopilot(cfg *config.ConfigStore) error {
159170 fmt.Println()
160171 fmt.Println("Waiting for authorization...")
161172
162− t, err := copilot.PollForToken(ctx, dc)
173+ t, err := copilot.PollForToken(loginCtx, dc)
163174 if err == copilot.ErrNotAvailable {
164175 fmt.Println()
165176 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 {
177188 }
178189
179190 if err := cmp.Or(
180− cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
181− cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.oauth", token),
191+ c.SetConfigField(loginCtx, wsID, config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
192+ c.SetConfigField(loginCtx, wsID, config.ScopeGlobal, "providers.copilot.oauth", token),
182193 ); err != nil {
183194 return err
184195 }
internal/cmd/root.go+331−37
@@ -7,24 +7,35 @@ import (
77 "errors"
88 "fmt"
99 "io"
10+ "io/fs"
1011 "log/slog"
12+ "net/url"
1113 "os"
14+ "os/exec"
1215 "path/filepath"
16+ "regexp"
1317 "strconv"
1418 "strings"
19+ "time"
1520
1621 tea "charm.land/bubbletea/v2"
17− "charm.land/fang/v2"
22+ fang "charm.land/fang/v2"
1823 "charm.land/lipgloss/v2"
1924 "github.com/charmbracelet/colorprofile"
2025 "github.com/charmbracelet/crush/internal/app"
26+ "github.com/charmbracelet/crush/internal/client"
2127 "github.com/charmbracelet/crush/internal/config"
2228 "github.com/charmbracelet/crush/internal/db"
2329 "github.com/charmbracelet/crush/internal/event"
30+ crushlog "github.com/charmbracelet/crush/internal/log"
2431 "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"
2535 "github.com/charmbracelet/crush/internal/ui/common"
2636 ui "github.com/charmbracelet/crush/internal/ui/model"
2737 "github.com/charmbracelet/crush/internal/version"
38+ "github.com/charmbracelet/crush/internal/workspace"
2839 uv "github.com/charmbracelet/ultraviolet"
2940 "github.com/charmbracelet/x/ansi"
3041 "github.com/charmbracelet/x/exp/charmtone"
@@ -32,10 +43,13 @@ import (
3243 "github.com/spf13/cobra"
3344)
3445
46+var clientHost string
47+
3548func init() {
3649 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
3750 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
3851 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)")
3953 rootCmd.Flags().BoolP("help", "h", false, "Help")
4054 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
4155 rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
@@ -88,15 +102,14 @@ crush --continue
88102 sessionID, _ := cmd.Flags().GetString("session")
89103 continueLast, _ := cmd.Flags().GetBool("continue")
90104
91− app, err := setupAppWithProgressBar(cmd)
105+ ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
92106 if err != nil {
93107 return err
94108 }
95− defer app.Shutdown()
109+ defer cleanup()
96110
97− // Resolve session ID if provided
98111 if sessionID != "" {
99− sess, err := resolveSessionID(cmd.Context(), app.Sessions, sessionID)
112+ sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
100113 if err != nil {
101114 return err
102115 }
@@ -105,19 +118,17 @@ crush --continue
105118
106119 event.AppInitialized()
107120
108− // Set up the TUI.
109− var env uv.Environ = os.Environ()
110−
111− com := common.DefaultCommon(app)
121+ com := common.DefaultCommon(ws)
112122 model := ui.New(com, sessionID, continueLast)
113123
124+ var env uv.Environ = os.Environ()
114125 program := tea.NewProgram(
115126 model,
116127 tea.WithEnvironment(env),
117128 tea.WithContext(cmd.Context()),
118− tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
129+ tea.WithFilter(ui.MouseEventFilter),
119130 )
120− go app.Subscribe(program)
131+ go ws.Subscribe(program)
121132
122133 if _, err := program.Run(); err != nil {
123134 event.Error(err)
@@ -147,6 +158,15 @@ const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{
147158`
148159
149160func 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+
150170 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
151171 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
152172 // finally prepend it in the version template.
@@ -183,25 +203,44 @@ func supportsProgressBar() bool {
183203 return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
184204}
185205
186−func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
187− app, err := setupApp(cmd)
188− if err != nil {
189− return nil, err
190− }
206+// useClientServer returns true when the client/server architecture is
207+// enabled via the CRUSH_CLIENT_SERVER environment variable.
208+func useClientServer() bool {
209+ v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
210+ return v
211+}
191212
192− // Check if progress bar is enabled in config (defaults to true if nil)
193− progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
194− if progressEnabled && supportsProgressBar() {
213+// setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
214+// terminal progress bar shown during initialization.
215+func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
216+ showProgress := supportsProgressBar()
217+ if showProgress {
195218 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
196− defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
197219 }
198220
199− return app, nil
221+ ws, cleanup, err := setupWorkspace(cmd)
222+
223+ if showProgress {
224+ _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
225+ }
226+
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)
200239}
201240
202−// setupApp handles the common setup logic for both interactive and non-interactive modes.
203−// It returns the app instance, config, cleanup function, and any error.
204−func setupApp(cmd *cobra.Command) (*app.App, error) {
241+// setupLocalWorkspace creates an in-process app.App and wraps it in an
242+// AppWorkspace.
243+func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
205244 debug, _ := cmd.Flags().GetBool("debug")
206245 yolo, _ := cmd.Flags().GetBool("yolo")
207246 dataDir, _ := cmd.Flags().GetString("data-dir")
@@ -209,47 +248,270 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
209248
210249 cwd, err := ResolveCwd(cmd)
211250 if err != nil {
212− return nil, err
251+ return nil, nil, err
213252 }
214253
215254 store, err := config.Init(cwd, dataDir, debug)
216255 if err != nil {
217− return nil, err
256+ return nil, nil, err
218257 }
219258
220259 cfg := store.Config()
221− if cfg.Permissions == nil {
222− cfg.Permissions = &config.Permissions{}
260+ store.Overrides().SkipPermissionRequests = yolo
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)
223264 }
224− cfg.Permissions.SkipRequests = yolo
225265
226− if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
227− return nil, err
266+ gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
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+ }
228271 }
229272
230− // Register this project in the centralized projects list.
231273 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
232274 slog.Warn("Failed to register project", "error", err)
233− // Non-fatal: continue even if registration fails
234275 }
235276
236− // Connect to DB; this will also run migrations.
237277 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
238278 if err != nil {
239− return nil, err
279+ return nil, nil, err
240280 }
241281
282+ logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
283+ crushlog.Setup(logFile, debug)
284+
242285 appInstance, err := app.New(ctx, conn, store)
243286 if err != nil {
287+ _ = conn.Close()
244288 slog.Error("Failed to create app instance", "error", err)
245− return nil, err
289+ return nil, nil, err
246290 }
247291
248292 if shouldEnableMetrics(cfg) {
249293 event.Init()
250294 }
251295
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
253515}
254516
255517func shouldEnableMetrics(cfg *config.Config) bool {
@@ -284,6 +546,38 @@ func MaybePrependStdin(prompt string) (string, error) {
284546 return string(bts) + "\n\n" + prompt, nil
285547}
286548
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+
287581func ResolveCwd(cmd *cobra.Command) (string, error) {
288582 cwd, _ := cmd.Flags().GetString("cwd")
289583 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 (
77 "os"
88 "os/signal"
99 "strings"
10+ "time"
1011
12+ "charm.land/lipgloss/v2"
1113 "charm.land/log/v2"
14+ "github.com/charmbracelet/crush/internal/client"
15+ "github.com/charmbracelet/crush/internal/config"
1216 "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"
1327 "github.com/spf13/cobra"
1428)
1529
@@ -59,31 +73,9 @@ crush run --continue "Follow up on your last response"
5973 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
6074 defer cancel()
6175
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−
8476 prompt := strings.Join(args, " ")
8577
86− prompt, err = MaybePrependStdin(prompt)
78+ prompt, err := MaybePrependStdin(prompt)
8779 if err != nil {
8880 slog.Error("Failed to read from stdin", "error", err)
8981 return err
@@ -103,7 +95,48 @@ crush run --continue "Follow up on your last response"
10395 event.SetContinueLastSession(true)
10496 }
10597
106− return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
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)
139+ return appWs.App().RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
107140 },
108141}
109142
@@ -116,3 +149,382 @@ func init() {
116149 runCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
117150 runCmd.MarkFlagsMutuallyExclusive("session", "continue")
118151}
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) {
213213}
214214
215215type 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
217− SkipRequests bool `json:"-"` // Automatically accept all permissions (YOLO mode)
216+ AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"`
218217}
219218
220219type TrailerStyle string
internal/config/load.go+28−7
@@ -22,7 +22,6 @@ import (
2222 "github.com/charmbracelet/crush/internal/env"
2323 "github.com/charmbracelet/crush/internal/fsext"
2424 "github.com/charmbracelet/crush/internal/home"
25− "github.com/charmbracelet/crush/internal/log"
2625 powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
2726 "github.com/qjebbs/go-jsons"
2827)
@@ -52,12 +51,6 @@ func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
5251 cfg.Options.Debug = true
5352 }
5453
55− // Setup logs
56− log.Setup(
57− filepath.Join(cfg.Options.DataDirectory, "logs", fmt.Sprintf("%s.log", appName)),
58− cfg.Options.Debug,
59− )
60−
6154 // Load workspace config last so it has highest priority.
6255 if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 {
6356 merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
@@ -749,6 +742,25 @@ func GlobalConfig() string {
749742 return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
750743}
751744
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+
752764// GlobalConfigData returns the path to the main data directory for the application.
753765// this config is used when the app overrides configurations instead of updating the global config.
754766func GlobalConfigData() string {
@@ -773,6 +785,15 @@ func GlobalConfigData() string {
773785 return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
774786}
775787
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+
776797func assignIfNil[T any](ptr **T, val T) {
777798 if *ptr == nil {
778799 *ptr = &val
internal/config/resolve.go+14
@@ -14,6 +14,20 @@ type VariableResolver interface {
1414 ResolveValue(value string) (string, error)
1515}
1616
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+
1731type Shell interface {
1832 Exec(ctx context.Context, command string) (stdout, stderr string, err error)
1933}
internal/config/scope.go+18
@@ -1,5 +1,7 @@
11package config
22
3+import "fmt"
4+
35// Scope determines which config file is targeted for read/write operations.
46type Scope int
57
@@ -9,3 +11,19 @@ const (
911 // ScopeWorkspace targets the workspace config (.crush/crush.json).
1012 ScopeWorkspace
1113)
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 (
1818 "github.com/tidwall/sjson"
1919)
2020
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+
2128// ConfigStore is the single entry point for all config access. It owns the
2229// pure-data Config, runtime state (working directory, resolver, known
2330// providers), and persistence to both global and workspace config files.
@@ -28,6 +35,7 @@ type ConfigStore struct {
2835 globalDataPath string // ~/.local/share/crush/crush.json
2936 workspacePath string // .crush/crush.json
3037 knownProviders []catwalk.Provider
38+ overrides RuntimeOverrides
3139}
3240
3341// Config returns the pure-data config struct (read-only after load).
@@ -63,20 +71,32 @@ func (s *ConfigStore) SetupAgents() {
6371 s.config.SetupAgents()
6472}
6573
74+// Overrides returns the runtime overrides for this store.
75+func (s *ConfigStore) Overrides() *RuntimeOverrides {
76+ return &s.overrides
77+}
78+
6679// 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) {
6881 switch scope {
6982 case ScopeWorkspace:
70− return s.workspacePath
83+ if s.workspacePath == "" {
84+ return "", ErrNoWorkspaceConfig
85+ }
86+ return s.workspacePath, nil
7187 default:
72− return s.globalDataPath
88+ return s.globalDataPath, nil
7389 }
7490}
7591
7692// HasConfigField checks whether a key exists in the config file for the given
7793// scope.
7894func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
79− data, err := os.ReadFile(s.configPath(scope))
95+ path, err := s.configPath(scope)
96+ if err != nil {
97+ return false
98+ }
99+ data, err := os.ReadFile(path)
80100 if err != nil {
81101 return false
82102 }
@@ -85,7 +105,10 @@ func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
85105
86106// SetConfigField sets a key/value pair in the config file for the given scope.
87107func (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+ }
89112 data, err := os.ReadFile(path)
90113 if err != nil {
91114 if os.IsNotExist(err) {
@@ -110,7 +133,10 @@ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
110133
111134// RemoveConfigField removes a key from the config file for the given scope.
112135func (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+ }
114140 data, err := os.ReadFile(path)
115141 if err != nil {
116142 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
22
33import (
44 "fmt"
5+ "io"
56 "log/slog"
67 "os"
78 "runtime/debug"
@@ -10,6 +11,7 @@ import (
1011 "time"
1112
1213 "github.com/charmbracelet/crush/internal/event"
14+ "github.com/charmbracelet/x/term"
1315 "gopkg.in/natefinch/lumberjack.v2"
1416)
1517
@@ -18,7 +20,7 @@ var (
1820 initialized atomic.Bool
1921)
2022
21−func Setup(logFile string, debug bool) {
23+func Setup(logFile string, debug bool, ws ...io.Writer) {
2224 initOnce.Do(func() {
2325 logRotator := &lumberjack.Logger{
2426 Filename: logFile,
@@ -33,12 +35,26 @@ func Setup(logFile string, debug bool) {
3335 level = slog.LevelDebug
3436 }
3537
36− logger := slog.NewJSONHandler(logRotator, &slog.HandlerOptions{
38+ opts := &slog.HandlerOptions{
3739 Level: level,
3840 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+ }
4056
41− slog.SetDefault(slog.New(logger))
57+ slog.SetDefault(slog.New(slog.NewMultiHandler(handlers...)))
4258 initialized.Store(true)
4359 })
4460}
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 @@
11package pubsub
22
3−import "context"
3+import (
4+ "context"
5+ "encoding/json"
6+)
47
58const (
69 CreatedEvent EventType = "created"
@@ -8,20 +11,43 @@ const (
811 DeletedEvent EventType = "deleted"
912)
1013
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.
1136type Subscriber[T any] interface {
1237 Subscribe(context.Context) <-chan Event[T]
1338}
1439
1540type (
16− // EventType identifies the type of event
41+ // EventType identifies the type of event.
1742 EventType string
1843
19− // Event represents an event in the lifecycle of a resource
44+ // Event represents an event in the lifecycle of a resource.
2045 Event[T any] struct {
21− Type EventType
22− Payload T
46+ Type EventType `json:"type"`
47+ Payload T `json:"payload"`
2348 }
2449
50+ // Publisher can publish events of type T.
2551 Publisher[T any] interface {
2652 Publish(EventType, T)
2753 }
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 (
77
88 tea "charm.land/bubbletea/v2"
99 "github.com/atotto/clipboard"
10− "github.com/charmbracelet/crush/internal/app"
1110 "github.com/charmbracelet/crush/internal/config"
1211 "github.com/charmbracelet/crush/internal/ui/styles"
1312 "github.com/charmbracelet/crush/internal/ui/util"
13+ "github.com/charmbracelet/crush/internal/workspace"
1414 uv "github.com/charmbracelet/ultraviolet"
1515)
1616
@@ -22,26 +22,21 @@ var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
2222
2323// Common defines common UI options and configurations.
2424type Common struct {
25− App *app.App
26− Styles *styles.Styles
25+ Workspace workspace.Workspace
26+ Styles *styles.Styles
2727}
2828
2929// Config returns the pure-data configuration associated with this [Common] instance.
3030func (c *Common) Config() *config.Config {
31− return c.App.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()
31+ return c.Workspace.Config()
3732}
3833
3934// DefaultCommon returns the default common UI configurations.
40−func DefaultCommon(app *app.App) *Common {
35+func DefaultCommon(ws workspace.Workspace) *Common {
4136 s := styles.DefaultStyles()
4237 return &Common{
43− App: app,
44− Styles: &s,
38+ Workspace: ws,
39+ Styles: &s,
4540 }
4641}
4742
internal/ui/dialog/api_key_input.go+2−4
@@ -291,7 +291,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
291291 Type: m.provider.Type,
292292 BaseURL: m.provider.APIEndpoint,
293293 }
294− err := providerConfig.TestConnection(m.com.Store().Resolver())
294+ err := providerConfig.TestConnection(m.com.Workspace.Resolver())
295295
296296 // intentionally wait for at least 750ms to make sure the user sees the spinner
297297 elapsed := time.Since(start)
@@ -307,9 +307,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
307307}
308308
309309func (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())
313311 if err != nil {
314312 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
315313 }
internal/ui/dialog/filepicker.go+1−1
@@ -123,7 +123,7 @@ func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
123123
124124// WorkingDir returns the current working directory of the [FilePicker].
125125func (f *FilePicker) WorkingDir() string {
126− wd := f.com.Store().WorkingDir()
126+ wd := f.com.Workspace.WorkingDir()
127127 if len(wd) > 0 {
128128 return wd
129129 }
internal/ui/dialog/models.go+1−1
@@ -485,7 +485,7 @@ func (m *Models) setProviderItems() error {
485485
486486 if len(validRecentItems) != len(recentItems) {
487487 // 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 {
489489 return fmt.Errorf("failed to update recent models: %w", err)
490490 }
491491 }
internal/ui/dialog/oauth.go+1−3
@@ -373,9 +373,7 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
373373}
374374
375375func (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)
379377 if err != nil {
380378 return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
381379 }
internal/ui/dialog/sessions.go+5−5
@@ -61,7 +61,7 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
6161 s := new(Session)
6262 s.sessionsMode = sessionsModeNormal
6363 s.com = com
64− sessions, err := com.App.Sessions.List(context.TODO())
64+ sessions, err := com.Workspace.ListSessions(context.TODO())
6565 if err != nil {
6666 return nil, err
6767 }
@@ -349,7 +349,7 @@ func (s *Session) removeSession(id string) {
349349
350350func (s *Session) deleteSessionCmd(id string) tea.Cmd {
351351 return func() tea.Msg {
352− err := s.com.App.Sessions.Delete(context.TODO(), id)
352+ err := s.com.Workspace.DeleteSession(context.TODO(), id)
353353 if err != nil {
354354 return util.NewErrorMsg(err)
355355 }
@@ -385,7 +385,7 @@ func (s *Session) updateSession(session session.Session) {
385385
386386func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
387387 return func() tea.Msg {
388− _, err := s.com.App.Sessions.Save(context.TODO(), session)
388+ _, err := s.com.Workspace.SaveSession(context.TODO(), session)
389389 if err != nil {
390390 return util.NewErrorMsg(err)
391391 }
@@ -399,11 +399,11 @@ func (s *Session) isCurrentSessionBusy() bool {
399399 return false
400400 }
401401
402− if s.com.App.AgentCoordinator == nil {
402+ if !s.com.Workspace.AgentIsReady() {
403403 return false
404404 }
405405
406− return s.com.App.AgentCoordinator.IsSessionBusy(sessionItem.ID())
406+ return s.com.Workspace.AgentIsSessionBusy(sessionItem.ID())
407407}
408408
409409// ShortHelp implements [help.KeyMap].
internal/ui/model/header.go+15−16
@@ -6,9 +6,7 @@ import (
66
77 "charm.land/lipgloss/v2"
88 "github.com/charmbracelet/crush/internal/config"
9− "github.com/charmbracelet/crush/internal/csync"
109 "github.com/charmbracelet/crush/internal/fsext"
11− "github.com/charmbracelet/crush/internal/lsp"
1210 "github.com/charmbracelet/crush/internal/session"
1311 "github.com/charmbracelet/crush/internal/ui/common"
1412 "github.com/charmbracelet/crush/internal/ui/styles"
@@ -62,7 +60,7 @@ func (h *header) drawHeader(
6260 h.width = width
6361 h.compact = compact
6462
65− if !compact || session == nil || h.com.App == nil {
63+ if !compact || session == nil {
6664 uv.NewStyledString(h.logo).Draw(scr, area)
6765 return
6866 }
@@ -75,10 +73,14 @@ func (h *header) drawHeader(
7573 b.WriteString(h.compactLogo)
7674
7775 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+ }
7880 details := renderHeaderDetails(
7981 h.com,
8082 session,
81− h.com.App.LSPManager.Clients(),
83+ lspErrorCount,
8284 detailsOpen,
8385 availDetailWidth,
8486 )
@@ -108,7 +110,7 @@ func (h *header) drawHeader(
108110func renderHeaderDetails(
109111 com *common.Common,
110112 session *session.Session,
111− lspClients *csync.Map[string, *lsp.Client],
113+ lspErrorCount int,
112114 detailsOpen bool,
113115 availWidth int,
114116) string {
@@ -116,20 +118,17 @@ func renderHeaderDetails(
116118
117119 var parts []string
118120
119− errorCount := 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)))
121+ if lspErrorCount > 0 {
122+ parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, lspErrorCount)))
126123 }
127124
128125 agentCfg := com.Config().Agents[config.AgentCoder]
129126 model := com.Config().GetModelByType(agentCfg.Model)
130− percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
131− formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
132− parts = append(parts, formattedPercentage)
127+ if model != nil && model.ContextWindow > 0 {
128+ percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
129+ formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
130+ parts = append(parts, formattedPercentage)
131+ }
133132
134133 const keystroke = "ctrl+d"
135134 if detailsOpen {
@@ -143,7 +142,7 @@ func renderHeaderDetails(
143142 metadata = dot + metadata
144143
145144 const dirTrimLimit = 4
146− cwd := fsext.DirTrim(fsext.PrettyPath(com.Store().WorkingDir()), dirTrimLimit)
145+ cwd := fsext.DirTrim(fsext.PrettyPath(com.Workspace.WorkingDir()), dirTrimLimit)
147146 cwd = t.Header.WorkingDir.Render(cwd)
148147
149148 result := cwd + metadata
internal/ui/model/history.go+2−2
@@ -22,9 +22,9 @@ func (m *UI) loadPromptHistory() tea.Cmd {
2222 var err error
2323
2424 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)
2626 } else {
27− messages, err = m.com.App.Messages.ListAllUserMessages(ctx)
27+ messages, err = m.com.Workspace.ListAllUserMessages(ctx)
2828 }
2929 if err != nil {
3030 slog.Error("Failed to load prompt history", "error", err)
internal/ui/model/landing.go+5−5
@@ -2,16 +2,16 @@ package model
22
33import (
44 "charm.land/lipgloss/v2"
5− "github.com/charmbracelet/crush/internal/agent"
65 "github.com/charmbracelet/crush/internal/ui/common"
6+ "github.com/charmbracelet/crush/internal/workspace"
77 "github.com/charmbracelet/ultraviolet/layout"
88)
99
1010// selectedLargeModel returns the currently selected large language model from
1111// the agent coordinator, if one exists.
12−func (m *UI) selectedLargeModel() *agent.Model {
13− if m.com.App.AgentCoordinator != nil {
14− model := m.com.App.AgentCoordinator.Model()
12+func (m *UI) selectedLargeModel() *workspace.AgentModel {
13+ if m.com.Workspace.AgentIsReady() {
14+ model := m.com.Workspace.AgentModel()
1515 return &model
1616 }
1717 return nil
@@ -22,7 +22,7 @@ func (m *UI) selectedLargeModel() *agent.Model {
2222func (m *UI) landingView() string {
2323 t := m.com.Styles
2424 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)
2626
2727 parts := []string{
2828 cwd,
internal/ui/model/lsp.go+8−10
@@ -7,16 +7,16 @@ import (
77 "strings"
88
99 "charm.land/lipgloss/v2"
10− "github.com/charmbracelet/crush/internal/app"
1110 "github.com/charmbracelet/crush/internal/lsp"
1211 "github.com/charmbracelet/crush/internal/ui/common"
1312 "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/crush/internal/workspace"
1414 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
1515)
1616
1717// LSPInfo wraps LSP client information with diagnostic counts by severity.
1818type LSPInfo struct {
19− app.LSPClientInfo
19+ workspace.LSPClientInfo
2020 Diagnostics map[protocol.DiagnosticSeverity]int
2121}
2222
@@ -25,20 +25,18 @@ type LSPInfo struct {
2525func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
2626 t := m.com.Styles
2727
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 {
2929 return strings.Compare(a.Name, b.Name)
3030 })
3131
3232 var lsps []LSPInfo
3333 for _, state := range states {
3434 lspErrs := map[protocol.DiagnosticSeverity]int{}
35− if client, ok := m.com.App.LSPManager.Clients().Get(state.Name); ok {
36− counts := client.GetDiagnosticCounts()
37− lspErrs[protocol.SeverityError] = counts.Error
38− lspErrs[protocol.SeverityWarning] = counts.Warning
39− lspErrs[protocol.SeverityHint] = counts.Hint
40− lspErrs[protocol.SeverityInformation] = counts.Information
41− }
35+ counts := m.com.Workspace.LSPGetDiagnosticCounts(state.Name)
36+ lspErrs[protocol.SeverityError] = counts.Error
37+ lspErrs[protocol.SeverityWarning] = counts.Warning
38+ lspErrs[protocol.SeverityHint] = counts.Hint
39+ lspErrs[protocol.SeverityInformation] = counts.Information
4240
4341 lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
4442 }
internal/ui/model/onboarding.go+7−8
@@ -9,8 +9,6 @@ import (
99 tea "charm.land/bubbletea/v2"
1010 "charm.land/lipgloss/v2"
1111
12− "github.com/charmbracelet/crush/internal/agent"
13− "github.com/charmbracelet/crush/internal/config"
1412 "github.com/charmbracelet/crush/internal/home"
1513 "github.com/charmbracelet/crush/internal/ui/common"
1614 "github.com/charmbracelet/crush/internal/ui/util"
@@ -19,7 +17,7 @@ import (
1917// markProjectInitialized marks the current project as initialized in the config.
2018func (m *UI) markProjectInitialized() tea.Msg {
2119 // TODO: handle error so we show it in the tui footer
22− err := config.MarkProjectInitialized(m.com.Store())
20+ err := m.com.Workspace.MarkProjectInitialized()
2321 if err != nil {
2422 slog.Error(err.Error())
2523 }
@@ -52,12 +50,13 @@ func (m *UI) initializeProject() tea.Cmd {
5250 if cmd := m.newSession(); cmd != nil {
5351 cmds = append(cmds, cmd)
5452 }
55− cfg := m.com.Store()
56−
5753 initialize := func() tea.Msg {
58− initPrompt, err := agent.InitializePrompt(cfg)
54+ initPrompt, err := m.com.Workspace.InitializePrompt()
5955 if err != nil {
60− return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()}
56+ return util.InfoMsg{
57+ Type: util.InfoTypeError,
58+ Msg: fmt.Sprintf("Failed to initialize project: %v", err),
59+ }
6160 }
6261 return sendMessageMsg{Content: initPrompt}
6362 }
@@ -78,7 +77,7 @@ func (m *UI) skipInitializeProject() tea.Cmd {
7877// initializeView renders the project initialization prompt with Yes/No buttons.
7978func (m *UI) initializeView() string {
8079 s := m.com.Styles.Initialize
81− cwd := home.Short(m.com.Store().WorkingDir())
80+ cwd := home.Short(m.com.Workspace.WorkingDir())
8281 initFile := m.com.Config().Options.InitializeAs
8382
8483 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() {
249249 if todosFocused && hasIncomplete {
250250 expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
251251 } else if queueFocused && hasQueue {
252− if m.com.App != nil && m.com.App.AgentCoordinator != nil {
253− queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID)
252+ if m.com.Workspace.AgentIsReady() {
253+ queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
254254 expandedList = queueList(queueItems, t)
255255 }
256256 }
internal/ui/model/session.go+4−4
@@ -66,7 +66,7 @@ type SessionFile struct {
6666// returns a sessionFilesLoadedMsg containing the processed session files.
6767func (m *UI) loadSession(sessionID string) tea.Cmd {
6868 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)
7070 if err != nil {
7171 return util.ReportError(err)
7272 }
@@ -76,7 +76,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
7676 return util.ReportError(err)
7777 }
7878
79− readFiles, err := m.com.App.FileTracker.ListReadFiles(context.Background(), sessionID)
79+ readFiles, err := m.com.Workspace.FileTrackerListReadFiles(context.Background(), sessionID)
8080 if err != nil {
8181 slog.Error("Failed to load read files for session", "error", err)
8282 }
@@ -90,7 +90,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
9090}
9191
9292func (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)
9494 if err != nil {
9595 return nil, err
9696 }
@@ -241,7 +241,7 @@ func (m *UI) startLSPs(paths []string) tea.Cmd {
241241 return func() tea.Msg {
242242 ctx := context.Background()
243243 for _, path := range paths {
244− m.com.App.LSPManager.Start(ctx, path)
244+ m.com.Workspace.LSPStart(ctx, path)
245245 }
246246 return nil
247247 }
internal/ui/model/sidebar.go+7−3
@@ -48,7 +48,11 @@ func (m *UI) modelInfo(width int) string {
4848 ModelContext: model.CatwalkCfg.ContextWindow,
4949 }
5050 }
51− return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width)
51+ var modelName string
52+ if model != nil {
53+ modelName = model.CatwalkCfg.Name
54+ }
55+ return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width)
5256}
5357
5458// 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) {
112116 height := area.Dy()
113117
114118 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)
116120 sidebarLogo := m.sidebarLogo
117121 if height < logoHeightBreakpoint {
118122 sidebarLogo = logo.SmallRender(m.com.Styles, width)
@@ -138,7 +142,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
138142
139143 lspSection := m.lspInfo(width, maxLSPs, true)
140144 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)
142146
143147 uv.NewStyledString(
144148 lipgloss.NewStyle().
internal/ui/model/ui.go+71−99
@@ -28,7 +28,6 @@ import (
2828 "github.com/charmbracelet/crush/internal/agent/notify"
2929 agenttools "github.com/charmbracelet/crush/internal/agent/tools"
3030 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
31− "github.com/charmbracelet/crush/internal/app"
3231 "github.com/charmbracelet/crush/internal/commands"
3332 "github.com/charmbracelet/crush/internal/config"
3433 "github.com/charmbracelet/crush/internal/fsext"
@@ -50,6 +49,7 @@ import (
5049 "github.com/charmbracelet/crush/internal/ui/styles"
5150 "github.com/charmbracelet/crush/internal/ui/util"
5251 "github.com/charmbracelet/crush/internal/version"
52+ "github.com/charmbracelet/crush/internal/workspace"
5353 uv "github.com/charmbracelet/ultraviolet"
5454 "github.com/charmbracelet/ultraviolet/layout"
5555 "github.com/charmbracelet/ultraviolet/screen"
@@ -213,7 +213,7 @@ type UI struct {
213213 }
214214
215215 // lsp
216− lspStates map[string]app.LSPClientInfo
216+ lspStates map[string]workspace.LSPClientInfo
217217
218218 // mcp
219219 mcpStates map[string]mcp.ClientInfo
@@ -315,7 +315,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
315315 completions: comp,
316316 attachments: attachments,
317317 todoSpinner: todoSpinner,
318− lspStates: make(map[string]app.LSPClientInfo),
318+ lspStates: make(map[string]workspace.LSPClientInfo),
319319 mcpStates: make(map[string]mcp.ClientInfo),
320320 notifyBackend: notification.NoopBackend{},
321321 notifyWindowFocused: true,
@@ -340,7 +340,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
340340 desiredFocus := uiFocusEditor
341341 if !com.Config().IsConfigured() {
342342 desiredState = uiOnboarding
343− } else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
343+ } else if n, _ := com.Workspace.ProjectNeedsInitialization(); n {
344344 desiredState = uiInitialize
345345 }
346346
@@ -386,11 +386,11 @@ func (m *UI) loadInitialSession() tea.Cmd {
386386 return m.loadSession(m.initialSessionID)
387387 case m.continueLastSession:
388388 return func() tea.Msg {
389− sess, err := m.com.App.Sessions.GetLast(context.Background())
390− if err != nil {
389+ sessions, err := m.com.Workspace.ListSessions(context.Background())
390+ if err != nil || len(sessions) == 0 {
391391 return nil
392392 }
393− return m.loadSession(sess.ID)()
393+ return m.loadSession(sessions[0].ID)()
394394 }
395395 default:
396396 return nil
@@ -463,7 +463,7 @@ func (m *UI) loadMCPrompts() tea.Msg {
463463func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
464464 var cmds []tea.Cmd
465465 if m.hasSession() && m.isAgentBusy() {
466− queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
466+ queueSize := m.com.Workspace.AgentQueuedPrompts(m.session.ID)
467467 if queueSize != m.promptQueue {
468468 m.promptQueue = queueSize
469469 m.updateLayoutAndSize()
@@ -498,7 +498,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
498498 m.session = msg.session
499499 m.sessionFiles = msg.files
500500 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)
502502 if err != nil {
503503 cmds = append(cmds, util.ReportError(err))
504504 break
@@ -615,8 +615,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
615615 m.renderPills()
616616 case pubsub.Event[history.File]:
617617 cmds = append(cmds, m.handleFileEvent(msg.Payload))
618− case pubsub.Event[app.LSPEvent]:
619− m.lspStates = app.GetLSPStates()
618+ case pubsub.Event[workspace.LSPEvent]:
619+ m.lspStates = m.com.Workspace.LSPGetStates()
620620 case pubsub.Event[mcp.Event]:
621621 switch msg.Payload.Type {
622622 case mcp.EventStateChanged:
@@ -625,11 +625,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
625625 m.loadMCPrompts,
626626 )
627627 case mcp.EventPromptsListChanged:
628− return m, handleMCPPromptsEvent(msg.Payload.Name)
628+ return m, handleMCPPromptsEvent(m.com.Workspace, msg.Payload.Name)
629629 case mcp.EventToolsListChanged:
630− return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
630+ return m, handleMCPToolsEvent(m.com.Workspace, msg.Payload.Name)
631631 case mcp.EventResourcesListChanged:
632− return m, handleMCPResourcesEvent(msg.Payload.Name)
632+ return m, handleMCPResourcesEvent(m.com.Workspace, msg.Payload.Name)
633633 }
634634 case pubsub.Event[permission.PermissionRequest]:
635635 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -836,6 +836,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
836836 m.textarea.MoveToEnd()
837837 cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
838838 case util.InfoMsg:
839+ if msg.Type == util.InfoTypeError {
840+ slog.Error("Error reported", "error", msg.Msg)
841+ }
839842 m.status.SetInfoMsg(msg)
840843 ttl := msg.TTL
841844 if ttl <= 0 {
@@ -872,7 +875,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
872875 } else {
873876 m.textarea.Placeholder = m.readyPlaceholder
874877 }
875− if m.com.App.Permissions.SkipRequests() {
878+ if m.com.Workspace.PermissionSkipRequests() {
876879 m.textarea.Placeholder = "Yolo mode!"
877880 }
878881 }
@@ -951,10 +954,10 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
951954 messageID := toolItem.MessageID()
952955
953956 // 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)
955958
956959 // Fetch nested messages.
957− nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
960+ nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
958961 if err != nil || len(nestedMsgs) == 0 {
959962 continue
960963 }
@@ -1156,7 +1159,7 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.
11561159
11571160 // Check if this is an agent tool session and parse it.
11581161 childSessionID := event.Payload.SessionID
1159− _, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
1162+ _, toolCallID, ok := m.com.Workspace.ParseAgentToolSessionID(childSessionID)
11601163 if !ok {
11611164 return nil
11621165 }
@@ -1288,8 +1291,8 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
12881291
12891292 // Command dialog messages.
12901293 case dialog.ActionToggleYoloMode:
1291− yolo := !m.com.App.Permissions.SkipRequests()
1292− m.com.App.Permissions.SetSkipRequests(yolo)
1294+ yolo := !m.com.Workspace.PermissionSkipRequests()
1295+ m.com.Workspace.PermissionSetSkipRequests(yolo)
12931296 m.setEditorPrompt(yolo)
12941297 m.dialog.CloseDialog(dialog.CommandsID)
12951298 case dialog.ActionToggleNotifications:
@@ -1297,7 +1300,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
12971300 if cfg != nil && cfg.Options != nil {
12981301 disabled := !cfg.Options.DisableNotifications
12991302 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 {
13011304 cmds = append(cmds, util.ReportError(err))
13021305 } else {
13031306 status := "enabled"
@@ -1323,7 +1326,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13231326 break
13241327 }
13251328 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)
13271330 if err != nil {
13281331 return util.ReportError(err)()
13291332 }
@@ -1362,10 +1365,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13621365
13631366 currentModel := cfg.Models[agentCfg.Model]
13641367 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 {
13661369 return util.ReportError(err)()
13671370 }
1368− m.com.App.UpdateAgentModel(context.TODO())
1371+ m.com.Workspace.UpdateAgentModel(context.TODO())
13691372 status := "disabled"
13701373 if currentModel.Think {
13711374 status = "enabled"
@@ -1382,7 +1385,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
13821385
13831386 isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
13841387 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 {
13861389 return util.ReportError(err)()
13871390 }
13881391 m.isTransparent = newValue
@@ -1430,7 +1433,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
14301433
14311434 // Attempt to import GitHub Copilot tokens from VSCode if available.
14321435 if isCopilot && !isConfigured() && !msg.ReAuthenticate {
1433− m.com.Store().ImportCopilot()
1436+ m.com.Workspace.ImportCopilot()
14341437 }
14351438
14361439 if !isConfigured() || msg.ReAuthenticate {
@@ -1441,18 +1444,18 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
14411444 break
14421445 }
14431446
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 {
14451448 cmds = append(cmds, util.ReportError(err))
14461449 } else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
14471450 // Ensure small model is set is unset.
1448− smallModel := m.com.App.GetDefaultSmallModel(providerID)
1449− if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
1451+ smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
1452+ if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
14501453 cmds = append(cmds, util.ReportError(err))
14511454 }
14521455 }
14531456
14541457 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 {
14561459 return util.ReportError(err)
14571460 }
14581461
@@ -1468,7 +1471,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
14681471 if isOnboarding {
14691472 m.setState(uiLanding, uiFocusEditor)
14701473 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 {
14721475 cmds = append(cmds, util.ReportError(err))
14731476 }
14741477 }
@@ -1492,13 +1495,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
14921495
14931496 currentModel := cfg.Models[agentCfg.Model]
14941497 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 {
14961499 cmds = append(cmds, util.ReportError(err))
14971500 break
14981501 }
14991502
15001503 cmds = append(cmds, func() tea.Msg {
1501− m.com.App.UpdateAgentModel(context.TODO())
1504+ m.com.Workspace.UpdateAgentModel(context.TODO())
15021505 return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
15031506 })
15041507 m.dialog.CloseDialog(dialog.ReasoningID)
@@ -1506,11 +1509,11 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
15061509 m.dialog.CloseDialog(dialog.PermissionsID)
15071510 switch msg.Action {
15081511 case dialog.PermissionAllow:
1509− m.com.App.Permissions.Grant(msg.Permission)
1512+ m.com.Workspace.PermissionGrant(msg.Permission)
15101513 case dialog.PermissionAllowForSession:
1511− m.com.App.Permissions.GrantPersistent(msg.Permission)
1514+ m.com.Workspace.PermissionGrantPersistent(msg.Permission)
15121515 case dialog.PermissionDeny:
1513− m.com.App.Permissions.Deny(msg.Permission)
1516+ m.com.Workspace.PermissionDeny(msg.Permission)
15141517 }
15151518
15161519 case dialog.ActionFilePickerSelected:
@@ -2115,7 +2118,7 @@ func (m *UI) View() tea.View {
21152118 }
21162119 v.MouseMode = tea.MouseModeCellMotion
21172120 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())
21192122
21202123 canvas := uv.NewScreenBuffer(m.width, m.height)
21212124 v.Cursor = m.Draw(canvas, canvas.Bounds())
@@ -2158,7 +2161,7 @@ func (m *UI) ShortHelp() []key.Binding {
21582161 cancelBinding := k.Chat.Cancel
21592162 if m.isCanceling {
21602163 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 {
21622165 cancelBinding.SetHelp("esc", "clear queue")
21632166 }
21642167 binds = append(binds, cancelBinding)
@@ -2237,7 +2240,7 @@ func (m *UI) FullHelp() [][]key.Binding {
22372240 cancelBinding := k.Chat.Cancel
22382241 if m.isCanceling {
22392242 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 {
22412244 cancelBinding.SetHelp("esc", "clear queue")
22422245 }
22432246 binds = append(binds, []key.Binding{cancelBinding})
@@ -2364,7 +2367,7 @@ func (m *UI) currentModelSupportsImages() bool {
23642367func (m *UI) toggleCompactMode() tea.Cmd {
23652368 m.forceCompactMode = !m.forceCompactMode
23662369
2367− err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
2370+ err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
23682371 if err != nil {
23692372 return util.ReportError(err)
23702373 }
@@ -2751,7 +2754,7 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
27512754
27522755 if m.hasSession() {
27532756 // 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)
27552758 if !lastRead.IsZero() {
27562759 if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
27572760 return nil
@@ -2792,9 +2795,8 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
27922795 heightCmd := m.handleTextareaHeightChange(prevHeight)
27932796
27942797 resourceCmd := func() tea.Msg {
2795− contents, err := mcp.ReadResource(
2798+ contents, err := m.com.Workspace.ReadMCPResource(
27962799 context.Background(),
2797− m.com.Store(),
27982800 item.MCPName,
27992801 item.URI,
28002802 )
@@ -2863,9 +2865,8 @@ func isWhitespace(b byte) bool {
28632865// isAgentBusy returns true if the agent coordinator exists and is currently
28642866// busy processing a request.
28652867func (m *UI) isAgentBusy() bool {
2866− return m.com.App != nil &&
2867− m.com.App.AgentCoordinator != nil &&
2868− m.com.App.AgentCoordinator.IsBusy()
2868+ return m.com.Workspace.AgentIsReady() &&
2869+ m.com.Workspace.AgentIsBusy()
28692870}
28702871
28712872// hasSession returns true if there is an active session with a valid ID.
@@ -2922,13 +2923,13 @@ func (m *UI) cacheSidebarLogo(width int) {
29222923
29232924// sendMessage sends a message with the given content and attachments.
29242925func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2925− if m.com.App.AgentCoordinator == nil {
2926+ if !m.com.Workspace.AgentIsReady() {
29262927 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
29272928 }
29282929
29292930 var cmds []tea.Cmd
29302931 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")
29322933 if err != nil {
29332934 return util.ReportError(err)
29342935 }
@@ -2945,8 +2946,8 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
29452946 ctx := context.Background()
29462947 cmds = append(cmds, func() tea.Msg {
29472948 for _, path := range m.sessionFileReads {
2948− m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
2949− m.com.App.LSPManager.Start(ctx, path)
2949+ m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
2950+ m.com.Workspace.LSPStart(ctx, path)
29502951 }
29512952 return nil
29522953 })
@@ -2954,7 +2955,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
29542955 // Capture session ID to avoid race with main goroutine updating m.session.
29552956 sessionID := m.session.ID
29562957 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...)
29582959 if err != nil {
29592960 isCancelErr := errors.Is(err, context.Canceled)
29602961 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
@@ -2963,7 +2964,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
29632964 }
29642965 return util.InfoMsg{
29652966 Type: util.InfoTypeError,
2966− Msg: err.Error(),
2967+ Msg: fmt.Sprintf("Failed to run agent: %v", err),
29672968 }
29682969 }
29692970 return nil
@@ -2988,15 +2989,14 @@ func (m *UI) cancelAgent() tea.Cmd {
29882989 return nil
29892990 }
29902991
2991− coordinator := m.com.App.AgentCoordinator
2992− if coordinator == nil {
2992+ if !m.com.Workspace.AgentIsReady() {
29932993 return nil
29942994 }
29952995
29962996 if m.isCanceling {
29972997 // Second escape press - actually cancel the agent.
29982998 m.isCanceling = false
2999− coordinator.Cancel(m.session.ID)
2999+ m.com.Workspace.AgentCancel(m.session.ID)
30003000 // Stop the spinning todo indicator.
30013001 m.todoIsSpinning = false
30023002 m.renderPills()
@@ -3004,8 +3004,8 @@ func (m *UI) cancelAgent() tea.Cmd {
30043004 }
30053005
30063006 // Check if there are queued prompts - if so, clear the queue.
3007− if coordinator.QueuedPrompts(m.session.ID) > 0 {
3008− coordinator.ClearQueue(m.session.ID)
3007+ if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
3008+ m.com.Workspace.AgentClearQueue(m.session.ID)
30093009 return nil
30103010 }
30113011
@@ -3230,7 +3230,7 @@ func (m *UI) newSession() tea.Cmd {
32303230 agenttools.ResetCache()
32313231 return tea.Batch(
32323232 func() tea.Msg {
3233− m.com.App.LSPManager.StopAll(context.Background())
3233+ m.com.Workspace.LSPStopAll(context.Background())
32343234 return nil
32353235 },
32363236 m.loadPromptHistory(),
@@ -3476,7 +3476,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
34763476
34773477 lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
34783478 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)
34803480 sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
34813481 uv.NewStyledString(
34823482 s.CompactDetails.View.
@@ -3494,7 +3494,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
34943494
34953495func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
34963496 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)
34983498 if err != nil {
34993499 // TODO: make this better
35003500 return util.ReportError(err)()
@@ -3521,34 +3521,30 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
35213521
35223522func (m *UI) handleStateChanged() tea.Cmd {
35233523 return func() tea.Msg {
3524− m.com.App.UpdateAgentModel(context.Background())
3524+ m.com.Workspace.UpdateAgentModel(context.Background())
35253525 return mcpStateChangedMsg{
3526− states: mcp.GetStates(),
3526+ states: m.com.Workspace.MCPGetStates(),
35273527 }
35283528 }
35293529}
35303530
3531−func handleMCPPromptsEvent(name string) tea.Cmd {
3531+func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
35323532 return func() tea.Msg {
3533− mcp.RefreshPrompts(context.Background(), name)
3533+ ws.MCPRefreshPrompts(context.Background(), name)
35343534 return nil
35353535 }
35363536}
35373537
3538−func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
3538+func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
35393539 return func() tea.Msg {
3540− mcp.RefreshTools(
3541− context.Background(),
3542− cfg,
3543− name,
3544− )
3540+ ws.RefreshMCPTools(context.Background(), name)
35453541 return nil
35463542 }
35473543}
35483544
3549−func handleMCPResourcesEvent(name string) tea.Cmd {
3545+func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
35503546 return func() tea.Msg {
3551− mcp.RefreshResources(context.Background(), name)
3547+ ws.MCPRefreshResources(context.Background(), name)
35523548 return nil
35533549 }
35543550}
@@ -3566,40 +3562,16 @@ func (m *UI) copyChatHighlight() tea.Cmd {
35663562}
35673563
35683564func (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−
35763565 ctx := context.Background()
3577− if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); 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)))()
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)))()
3566+ if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
3567+ return util.ReportError(err)()
35893568 }
35903569
35913570 return util.NewInfoMsg("Docker MCP enabled and started successfully")
35923571}
35933572
35943573func (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 {
36033575 return util.ReportError(err)()
36043576 }
36053577
internal/ui/model/ui_test.go+9−21
@@ -1,15 +1,13 @@
11package model
22
33import (
4− "reflect"
54 "testing"
6− "unsafe"
75
86 "charm.land/catwalk/pkg/catwalk"
9− "github.com/charmbracelet/crush/internal/app"
107 "github.com/charmbracelet/crush/internal/config"
118 "github.com/charmbracelet/crush/internal/csync"
129 "github.com/charmbracelet/crush/internal/ui/common"
10+ "github.com/charmbracelet/crush/internal/workspace"
1311 "github.com/stretchr/testify/require"
1412)
1513
@@ -79,29 +77,19 @@ func TestCurrentModelSupportsImages(t *testing.T) {
7977func newTestUIWithConfig(t *testing.T, cfg *config.Config) *UI {
8078 t.Helper()
8179
82− store := &config.ConfigStore{}
83− setUnexportedField(t, store, "config", cfg)
84−
85− appInstance := &app.App{}
86− setUnexportedField(t, appInstance, "config", store)
87−
8880 return &UI{
8981 com: &common.Common{
90− App: appInstance,
82+ Workspace: &testWorkspace{cfg: cfg},
9183 },
9284 }
9385}
9486
95−func setUnexportedField(t *testing.T, target any, name string, value any) {
96− t.Helper()
97−
98− v := reflect.ValueOf(target)
99− require.Equal(t, reflect.Pointer, v.Kind())
100− require.False(t, v.IsNil())
101−
102− field := v.Elem().FieldByName(name)
103− require.Truef(t, field.IsValid(), "field %q not found", name)
87+// testWorkspace is a minimal [workspace.Workspace] stub for unit tests.
88+type testWorkspace struct {
89+ workspace.Workspace
90+ cfg *config.Config
91+}
10492
105− fieldValue := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem()
106− fieldValue.Set(reflect.ValueOf(value))
93+func (w *testWorkspace) Config() *config.Config {
94+ return w.cfg
10795}
internal/ui/util/util.go−2
@@ -4,7 +4,6 @@ package util
44import (
55 "context"
66 "errors"
7− "log/slog"
87 "os/exec"
98 "time"
109
@@ -23,7 +22,6 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
2322}
2423
2524func ReportError(err error) tea.Cmd {
26− slog.Error("Error reported", "error", err)
2725 return CmdHandler(NewErrorMsg(err))
2826}
2927
internal/version/version.go+5−2
@@ -2,9 +2,12 @@ package version
22
33import "runtime/debug"
44
5−// Build-time parameters set via -ldflags
5+// Build-time parameters set via -ldflags.
66
7−var Version = "devel"
7+var (
8+ Version = "devel"
9+ Commit = "unknown"
10+)
811
912// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
1013// 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
111package main
212
313import (