Merge pull request #2455 from charmbracelet/server-client-2

Ayman Bagabas created

Feat/Refactor: Server Client architecture and API

Change summary

Taskfile.yaml                          |   13 
go.mod                                 |   15 
go.sum                                 |   30 
internal/app/app.go                    |   16 
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                  |   45 
internal/cmd/root.go                   |  368 ++
internal/cmd/root_other.go             |   16 
internal/cmd/root_windows.go           |   18 
internal/cmd/run.go                    |  460 +++
internal/cmd/server.go                 |   99 
internal/cmd/server_other.go           |   13 
internal/cmd/server_windows.go         |   10 
internal/config/config.go              |    3 
internal/config/load.go                |   35 
internal/config/resolve.go             |   14 
internal/config/scope.go               |   18 
internal/config/store.go               |   38 
internal/config/store_test.go          |  152 +
internal/log/log.go                    |   24 
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              |   36 
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           |   19 
internal/ui/dialog/api_key_input.go    |    6 
internal/ui/dialog/filepicker.go       |    2 
internal/ui/dialog/models.go           |    2 
internal/ui/dialog/oauth.go            |    4 
internal/ui/dialog/sessions.go         |   10 
internal/ui/model/header.go            |   31 
internal/ui/model/history.go           |    4 
internal/ui/model/landing.go           |   10 
internal/ui/model/lsp.go               |   18 
internal/ui/model/onboarding.go        |   15 
internal/ui/model/pills.go             |    4 
internal/ui/model/session.go           |    8 
internal/ui/model/sidebar.go           |   10 
internal/ui/model/ui.go                |  170 
internal/ui/model/ui_test.go           |   30 
internal/ui/util/util.go               |    2 
internal/version/version.go            |    7 
internal/workspace/app_workspace.go    |  389 +++
internal/workspace/client_workspace.go |  773 ++++++
internal/workspace/workspace.go        |  152 +
main.go                                |   10 
76 files changed, 18,070 insertions(+), 304 deletions(-)

Detailed changes

Taskfile.yaml 🔗

@@ -196,6 +196,19 @@ tasks:
       - go get charm.land/catwalk@latest
       - go mod tidy
 
+  swag:
+    desc: Generate OpenAPI spec from swag annotations
+    cmds:
+      - 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
+    sources:
+      - internal/server/*.go
+      - internal/proto/*.go
+      - main.go
+    generates:
+      - internal/swagger/docs.go
+      - internal/swagger/swagger.json
+      - internal/swagger/swagger.yaml
+
   sqlc:
     desc: Generate code using SQLC
     cmds:

go.mod 🔗

@@ -14,6 +14,7 @@ require (
 	charm.land/x/vcr v0.1.1
 	github.com/JohannesKaufmann/html-to-markdown v1.6.0
 	github.com/MakeNowJust/heredoc v1.0.0
+	github.com/Microsoft/go-winio v0.6.2
 	github.com/PuerkitoBio/goquery v1.12.0
 	github.com/alecthomas/chroma/v2 v2.23.1
 	github.com/atotto/clipboard v0.1.4
@@ -59,12 +60,15 @@ require (
 	github.com/sourcegraph/jsonrpc2 v0.2.1
 	github.com/spf13/cobra v1.10.2
 	github.com/stretchr/testify v1.11.1
+	github.com/swaggo/http-swagger/v2 v2.0.2
+	github.com/swaggo/swag v1.16.6
 	github.com/tidwall/gjson v1.18.0
 	github.com/tidwall/sjson v1.2.5
 	github.com/zeebo/xxh3 v1.1.0
 	go.uber.org/goleak v1.3.0
 	golang.org/x/net v0.52.0
 	golang.org/x/sync v0.20.0
+	golang.org/x/sys v0.42.0
 	golang.org/x/text v0.35.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gopkg.in/yaml.v3 v3.0.1
@@ -81,6 +85,7 @@ require (
 	git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
+	github.com/KyleBanks/depth v1.2.1 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
 	github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
@@ -119,6 +124,10 @@ require (
 	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-ole/go-ole v1.3.0 // indirect
+	github.com/go-openapi/jsonpointer v0.19.5 // indirect
+	github.com/go-openapi/jsonreference v0.20.0 // indirect
+	github.com/go-openapi/spec v0.20.6 // indirect
+	github.com/go-openapi/swag v0.19.15 // indirect
 	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
 	github.com/goccy/go-json v0.10.5 // indirect
 	github.com/goccy/go-yaml v1.19.2 // indirect
@@ -135,6 +144,7 @@ require (
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/jackmordaunt/icns/v3 v3.0.1 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
+	github.com/josharian/intern v1.0.0 // indirect
 	github.com/kaptinlin/go-i18n v0.2.12 // indirect
 	github.com/kaptinlin/jsonpointer v0.4.17 // indirect
 	github.com/kaptinlin/jsonschema v0.7.6 // indirect
@@ -166,6 +176,7 @@ require (
 	github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect
 	github.com/sethvargo/go-retry v0.3.0 // indirect
 	github.com/spf13/pflag v1.0.9 // indirect
+	github.com/swaggo/files/v2 v2.0.0 // indirect
 	github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
 	github.com/tidwall/match v1.1.1 // indirect
 	github.com/tidwall/pretty v1.2.1 // indirect
@@ -187,10 +198,11 @@ require (
 	golang.org/x/crypto v0.49.0 // indirect
 	golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
 	golang.org/x/image v0.38.0 // indirect
+	golang.org/x/mod v0.34.0 // indirect
 	golang.org/x/oauth2 v0.36.0 // indirect
-	golang.org/x/sys v0.42.0 // indirect
 	golang.org/x/term v0.41.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
+	golang.org/x/tools v0.43.0 // indirect
 	google.golang.org/api v0.271.0 // indirect
 	google.golang.org/genai v1.51.0 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
@@ -199,6 +211,7 @@ require (
 	gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290 // indirect
 	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
 	modernc.org/libc v1.70.0 // indirect
 	modernc.org/mathutil v1.7.1 // indirect
 	modernc.org/memory v1.11.0 // indirect

go.sum 🔗

@@ -36,8 +36,12 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mx
 github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
 github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
 github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
+github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
+github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
 github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
 github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
 github.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
 github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
 github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
 github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
 github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
@@ -190,6 +195,16 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
 github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
+github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
+github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
+github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
+github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
+github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ=
+github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
+github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
+github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
+github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
 github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
 github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
 github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
@@ -243,6 +258,7 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
 github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d h1:on25kP+Sx7sxUMRQiA8gdcToAGet4DK/EIA30mXre+4=
 github.com/jordanella/go-ansi-paintbrush v0.0.0-20240728195301-b7ad996ecf3d/go.mod h1:SV0W0APWP9MZ1/gfDQ/NzzTlWdIgYZ/ZbpN4d/UXRYw=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
 github.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4=
 github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=
@@ -269,6 +285,9 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
 github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
 github.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
 github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
 github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
 github.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
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
+github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
+github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg=
+github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ=
+github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
+github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
 github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
 github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
 github.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
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.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=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=

internal/app/app.go 🔗

@@ -81,7 +81,7 @@ func New(ctx context.Context, conn *sql.DB, store *config.ConfigStore) (*App, er
 	messages := message.NewService(q)
 	files := history.NewService(q, conn)
 	cfg := store.Config()
-	skipPermissionsRequests := cfg.Permissions != nil && cfg.Permissions.SkipRequests
+	skipPermissionsRequests := store.Overrides().SkipPermissionRequests
 	var allowedTools []string
 	if cfg.Permissions != nil && cfg.Permissions.AllowedTools != nil {
 		allowedTools = cfg.Permissions.AllowedTools
@@ -152,6 +152,20 @@ func (app *App) Store() *config.ConfigStore {
 	return app.config
 }
 
+// Events returns the events channel for the application.
+func (app *App) Events() <-chan tea.Msg {
+	return app.events
+}
+
+// SendEvent pushes a message into the application's events channel.
+// It is non-blocking; the message is dropped if the channel is full.
+func (app *App) SendEvent(msg tea.Msg) {
+	select {
+	case app.events <- msg:
+	default:
+	}
+}
+
 // AgentNotifications returns the broker for agent notification events.
 func (app *App) AgentNotifications() *pubsub.Broker[notify.Notification] {
 	return app.agentNotifications

internal/backend/agent.go 🔗

@@ -0,0 +1,144 @@
+package backend
+
+import (
+	"context"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/proto"
+)
+
+// SendMessage sends a prompt to the agent coordinator for the given
+// workspace and session.
+func (b *Backend) SendMessage(ctx context.Context, workspaceID string, msg proto.AgentMessage) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	if ws.AgentCoordinator == nil {
+		return ErrAgentNotInitialized
+	}
+
+	_, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt)
+	return err
+}
+
+// GetAgentInfo returns the agent's model and busy status.
+func (b *Backend) GetAgentInfo(workspaceID string) (proto.AgentInfo, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return proto.AgentInfo{}, err
+	}
+
+	var agentInfo proto.AgentInfo
+	if ws.AgentCoordinator != nil {
+		m := ws.AgentCoordinator.Model()
+		agentInfo = proto.AgentInfo{
+			Model:    m.CatwalkCfg,
+			ModelCfg: m.ModelCfg,
+			IsBusy:   ws.AgentCoordinator.IsBusy(),
+			IsReady:  true,
+		}
+	}
+	return agentInfo, nil
+}
+
+// InitAgent initializes the coder agent for the workspace.
+func (b *Backend) InitAgent(ctx context.Context, workspaceID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	return ws.InitCoderAgent(ctx)
+}
+
+// UpdateAgent reloads the agent model configuration.
+func (b *Backend) UpdateAgent(ctx context.Context, workspaceID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	return ws.UpdateAgentModel(ctx)
+}
+
+// CancelSession cancels an ongoing agent operation for the given
+// session.
+func (b *Backend) CancelSession(workspaceID, sessionID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	if ws.AgentCoordinator != nil {
+		ws.AgentCoordinator.Cancel(sessionID)
+	}
+	return nil
+}
+
+// SummarizeSession triggers a session summarization.
+func (b *Backend) SummarizeSession(ctx context.Context, workspaceID, sessionID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	if ws.AgentCoordinator == nil {
+		return ErrAgentNotInitialized
+	}
+
+	return ws.AgentCoordinator.Summarize(ctx, sessionID)
+}
+
+// QueuedPrompts returns the number of queued prompts for the session.
+func (b *Backend) QueuedPrompts(workspaceID, sessionID string) (int, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return 0, err
+	}
+
+	if ws.AgentCoordinator == nil {
+		return 0, nil
+	}
+
+	return ws.AgentCoordinator.QueuedPrompts(sessionID), nil
+}
+
+// ClearQueue clears the prompt queue for the session.
+func (b *Backend) ClearQueue(workspaceID, sessionID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	if ws.AgentCoordinator != nil {
+		ws.AgentCoordinator.ClearQueue(sessionID)
+	}
+	return nil
+}
+
+// QueuedPromptsList returns the list of queued prompt strings for a
+// session.
+func (b *Backend) QueuedPromptsList(workspaceID, sessionID string) ([]string, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	if ws.AgentCoordinator == nil {
+		return nil, nil
+	}
+
+	return ws.AgentCoordinator.QueuedPromptsList(sessionID), nil
+}
+
+// GetDefaultSmallModel returns the default small model for a provider.
+func (b *Backend) GetDefaultSmallModel(workspaceID, providerID string) (config.SelectedModel, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return config.SelectedModel{}, err
+	}
+
+	return ws.GetDefaultSmallModel(providerID), nil
+}

internal/backend/backend.go 🔗

@@ -0,0 +1,204 @@
+// Package backend provides transport-agnostic operations for managing
+// workspaces, sessions, agents, permissions, and events. It is consumed
+// by protocol-specific layers such as HTTP (server) and ACP.
+package backend
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"runtime"
+
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/db"
+	"github.com/charmbracelet/crush/internal/proto"
+	"github.com/charmbracelet/crush/internal/ui/util"
+	"github.com/charmbracelet/crush/internal/version"
+	"github.com/google/uuid"
+)
+
+// Common errors returned by backend operations.
+var (
+	ErrWorkspaceNotFound       = errors.New("workspace not found")
+	ErrLSPClientNotFound       = errors.New("LSP client not found")
+	ErrAgentNotInitialized     = errors.New("agent coordinator not initialized")
+	ErrPathRequired            = errors.New("path is required")
+	ErrInvalidPermissionAction = errors.New("invalid permission action")
+	ErrUnknownCommand          = errors.New("unknown command")
+)
+
+// ShutdownFunc is called when the backend needs to trigger a server
+// shutdown (e.g. when the last workspace is removed).
+type ShutdownFunc func()
+
+// Backend provides transport-agnostic business logic for the Crush
+// server. It manages workspaces and delegates to [app.App] services.
+type Backend struct {
+	workspaces *csync.Map[string, *Workspace]
+	cfg        *config.ConfigStore
+	ctx        context.Context
+	shutdownFn ShutdownFunc
+}
+
+// Workspace represents a running [app.App] workspace with its
+// associated resources and state.
+type Workspace struct {
+	*app.App
+	ID   string
+	Path string
+	Cfg  *config.ConfigStore
+	Env  []string
+}
+
+// New creates a new [Backend].
+func New(ctx context.Context, cfg *config.ConfigStore, shutdownFn ShutdownFunc) *Backend {
+	return &Backend{
+		workspaces: csync.NewMap[string, *Workspace](),
+		cfg:        cfg,
+		ctx:        ctx,
+		shutdownFn: shutdownFn,
+	}
+}
+
+// GetWorkspace retrieves a workspace by ID.
+func (b *Backend) GetWorkspace(id string) (*Workspace, error) {
+	ws, ok := b.workspaces.Get(id)
+	if !ok {
+		return nil, ErrWorkspaceNotFound
+	}
+	return ws, nil
+}
+
+// ListWorkspaces returns all running workspaces.
+func (b *Backend) ListWorkspaces() []proto.Workspace {
+	workspaces := []proto.Workspace{}
+	for _, ws := range b.workspaces.Seq2() {
+		workspaces = append(workspaces, workspaceToProto(ws))
+	}
+	return workspaces
+}
+
+// CreateWorkspace initializes a new workspace from the given
+// parameters. It creates the config, database connection, and
+// [app.App] instance.
+func (b *Backend) CreateWorkspace(args proto.Workspace) (*Workspace, proto.Workspace, error) {
+	if args.Path == "" {
+		return nil, proto.Workspace{}, ErrPathRequired
+	}
+
+	id := uuid.New().String()
+	cfg, err := config.Init(args.Path, args.DataDir, args.Debug)
+	if err != nil {
+		return nil, proto.Workspace{}, fmt.Errorf("failed to initialize config: %w", err)
+	}
+
+	cfg.Overrides().SkipPermissionRequests = args.YOLO
+
+	if err := createDotCrushDir(cfg.Config().Options.DataDirectory); err != nil {
+		return nil, proto.Workspace{}, fmt.Errorf("failed to create data directory: %w", err)
+	}
+
+	conn, err := db.Connect(b.ctx, cfg.Config().Options.DataDirectory)
+	if err != nil {
+		return nil, proto.Workspace{}, fmt.Errorf("failed to connect to database: %w", err)
+	}
+
+	appWorkspace, err := app.New(b.ctx, conn, cfg)
+	if err != nil {
+		return nil, proto.Workspace{}, fmt.Errorf("failed to create app workspace: %w", err)
+	}
+
+	ws := &Workspace{
+		App:  appWorkspace,
+		ID:   id,
+		Path: args.Path,
+		Cfg:  cfg,
+		Env:  args.Env,
+	}
+
+	b.workspaces.Set(id, ws)
+
+	if args.Version != "" && args.Version != version.Version {
+		slog.Warn("Client/server version mismatch",
+			"client", args.Version,
+			"server", version.Version,
+		)
+		appWorkspace.SendEvent(util.NewWarnMsg(fmt.Sprintf(
+			"Server version %q differs from client version %q. Consider restarting the server.",
+			version.Version, args.Version,
+		)))
+	}
+
+	result := proto.Workspace{
+		ID:      id,
+		Path:    args.Path,
+		DataDir: cfg.Config().Options.DataDirectory,
+		Debug:   cfg.Config().Options.Debug,
+		YOLO:    cfg.Overrides().SkipPermissionRequests,
+		Config:  cfg.Config(),
+		Env:     args.Env,
+	}
+
+	return ws, result, nil
+}
+
+// DeleteWorkspace shuts down and removes a workspace. If it was the
+// last workspace, the shutdown callback is invoked.
+func (b *Backend) DeleteWorkspace(id string) {
+	ws, ok := b.workspaces.Get(id)
+	if ok {
+		ws.Shutdown()
+	}
+	b.workspaces.Del(id)
+
+	if b.workspaces.Len() == 0 && b.shutdownFn != nil {
+		slog.Info("Last workspace removed, shutting down server...")
+		b.shutdownFn()
+	}
+}
+
+// GetWorkspaceProto returns the proto representation of a workspace.
+func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
+	ws, err := b.GetWorkspace(id)
+	if err != nil {
+		return proto.Workspace{}, err
+	}
+	return workspaceToProto(ws), nil
+}
+
+// VersionInfo returns server version information.
+func (b *Backend) VersionInfo() proto.VersionInfo {
+	return proto.VersionInfo{
+		Version:   version.Version,
+		Commit:    version.Commit,
+		GoVersion: runtime.Version(),
+		Platform:  fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
+	}
+}
+
+// Config returns the server-level configuration.
+func (b *Backend) Config() *config.ConfigStore {
+	return b.cfg
+}
+
+// Shutdown initiates a graceful server shutdown.
+func (b *Backend) Shutdown() {
+	if b.shutdownFn != nil {
+		b.shutdownFn()
+	}
+}
+
+func workspaceToProto(ws *Workspace) proto.Workspace {
+	cfg := ws.Cfg.Config()
+	return proto.Workspace{
+		ID:      ws.ID,
+		Path:    ws.Path,
+		YOLO:    ws.Cfg.Overrides().SkipPermissionRequests,
+		DataDir: cfg.Options.DataDirectory,
+		Debug:   cfg.Options.Debug,
+		Config:  cfg,
+	}
+}

internal/backend/config.go 🔗

@@ -0,0 +1,214 @@
+package backend
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/charmbracelet/crush/internal/agent"
+	mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/commands"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/oauth"
+)
+
+// MCPResourceContents holds the contents of an MCP resource returned
+// by the backend.
+type MCPResourceContents struct {
+	URI      string `json:"uri"`
+	MIMEType string `json:"mime_type,omitempty"`
+	Text     string `json:"text,omitempty"`
+	Blob     []byte `json:"blob,omitempty"`
+}
+
+// SetConfigField sets a key/value pair in the config file for the
+// given scope.
+func (b *Backend) SetConfigField(workspaceID string, scope config.Scope, key string, value any) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+	return ws.Cfg.SetConfigField(scope, key, value)
+}
+
+// RemoveConfigField removes a key from the config file for the given
+// scope.
+func (b *Backend) RemoveConfigField(workspaceID string, scope config.Scope, key string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+	return ws.Cfg.RemoveConfigField(scope, key)
+}
+
+// UpdatePreferredModel updates the preferred model for the given type
+// and persists it to the config file at the given scope.
+func (b *Backend) UpdatePreferredModel(workspaceID string, scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+	return ws.Cfg.UpdatePreferredModel(scope, modelType, model)
+}
+
+// SetCompactMode sets the compact mode setting and persists it.
+func (b *Backend) SetCompactMode(workspaceID string, scope config.Scope, enabled bool) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+	return ws.Cfg.SetCompactMode(scope, enabled)
+}
+
+// SetProviderAPIKey sets the API key for a provider and persists it.
+func (b *Backend) SetProviderAPIKey(workspaceID string, scope config.Scope, providerID string, apiKey any) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+	return ws.Cfg.SetProviderAPIKey(scope, providerID, apiKey)
+}
+
+// ImportCopilot attempts to import a GitHub Copilot token from disk.
+func (b *Backend) ImportCopilot(workspaceID string) (*oauth.Token, bool, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, false, err
+	}
+	token, ok := ws.Cfg.ImportCopilot()
+	return token, ok, nil
+}
+
+// RefreshOAuthToken refreshes the OAuth token for a provider.
+func (b *Backend) RefreshOAuthToken(ctx context.Context, workspaceID string, scope config.Scope, providerID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+	return ws.Cfg.RefreshOAuthToken(ctx, scope, providerID)
+}
+
+// ProjectNeedsInitialization checks whether the project in this
+// workspace needs initialization.
+func (b *Backend) ProjectNeedsInitialization(workspaceID string) (bool, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return false, err
+	}
+	return config.ProjectNeedsInitialization(ws.Cfg)
+}
+
+// MarkProjectInitialized marks the project as initialized.
+func (b *Backend) MarkProjectInitialized(workspaceID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+	return config.MarkProjectInitialized(ws.Cfg)
+}
+
+// InitializePrompt builds the initialization prompt for the workspace.
+func (b *Backend) InitializePrompt(workspaceID string) (string, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return "", err
+	}
+	return agent.InitializePrompt(ws.Cfg)
+}
+
+// EnableDockerMCP validates Docker MCP availability, stages the
+// configuration, starts the MCP client, and persists the config.
+func (b *Backend) EnableDockerMCP(ctx context.Context, workspaceID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	mcpConfig, err := ws.Cfg.PrepareDockerMCPConfig()
+	if err != nil {
+		return err
+	}
+
+	if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, ws.Cfg); err != nil {
+		disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
+		delete(ws.Cfg.Config().MCP, config.DockerMCPName)
+		return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
+	}
+
+	if err := ws.Cfg.PersistDockerMCPConfig(mcpConfig); err != nil {
+		disableErr := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName)
+		delete(ws.Cfg.Config().MCP, config.DockerMCPName)
+		return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
+	}
+
+	return nil
+}
+
+// DisableDockerMCP closes the Docker MCP client, removes the
+// configuration, and persists the change.
+func (b *Backend) DisableDockerMCP(workspaceID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	if err := mcptools.DisableSingle(ws.Cfg, config.DockerMCPName); err != nil {
+		return fmt.Errorf("failed to disable docker MCP: %w", err)
+	}
+
+	if err := ws.Cfg.DisableDockerMCP(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// RefreshMCPTools refreshes the tools for a named MCP server.
+func (b *Backend) RefreshMCPTools(ctx context.Context, workspaceID, name string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+	mcptools.RefreshTools(ctx, ws.Cfg, name)
+	return nil
+}
+
+// ReadMCPResource reads a resource from a named MCP server.
+func (b *Backend) ReadMCPResource(ctx context.Context, workspaceID, name, uri string) ([]MCPResourceContents, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+	contents, err := mcptools.ReadResource(ctx, ws.Cfg, name, uri)
+	if err != nil {
+		return nil, err
+	}
+	result := make([]MCPResourceContents, len(contents))
+	for i, c := range contents {
+		result[i] = MCPResourceContents{
+			URI:      c.URI,
+			MIMEType: c.MIMEType,
+			Text:     c.Text,
+			Blob:     c.Blob,
+		}
+	}
+	return result, nil
+}
+
+// GetMCPPrompt retrieves a prompt from a named MCP server.
+func (b *Backend) GetMCPPrompt(workspaceID, clientID, promptID string, args map[string]string) (string, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return "", err
+	}
+	return commands.GetMCPPrompt(ws.Cfg, clientID, promptID, args)
+}
+
+// GetWorkingDir returns the working directory for a workspace.
+func (b *Backend) GetWorkingDir(workspaceID string) (string, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return "", err
+	}
+	return ws.Cfg.WorkingDir(), nil
+}

internal/backend/events.go 🔗

@@ -0,0 +1,107 @@
+package backend
+
+import (
+	"context"
+
+	tea "charm.land/bubbletea/v2"
+
+	mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/config"
+)
+
+// SubscribeEvents returns the event channel for a workspace's app.
+func (b *Backend) SubscribeEvents(workspaceID string) (<-chan tea.Msg, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	return ws.Events(), nil
+}
+
+// GetLSPStates returns the state of all LSP clients.
+func (b *Backend) GetLSPStates(workspaceID string) (map[string]app.LSPClientInfo, error) {
+	_, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	return app.GetLSPStates(), nil
+}
+
+// GetLSPDiagnostics returns diagnostics for a specific LSP client in
+// the workspace.
+func (b *Backend) GetLSPDiagnostics(workspaceID, lspName string) (any, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	for name, client := range ws.LSPManager.Clients().Seq2() {
+		if name == lspName {
+			return client.GetDiagnostics(), nil
+		}
+	}
+
+	return nil, ErrLSPClientNotFound
+}
+
+// GetWorkspaceConfig returns the workspace-level configuration.
+func (b *Backend) GetWorkspaceConfig(workspaceID string) (*config.Config, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	return ws.Cfg.Config(), nil
+}
+
+// GetWorkspaceProviders returns the configured providers for a
+// workspace.
+func (b *Backend) GetWorkspaceProviders(workspaceID string) (any, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	providers, _ := config.Providers(ws.Cfg.Config())
+	return providers, nil
+}
+
+// LSPStart starts an LSP server for the given path.
+func (b *Backend) LSPStart(ctx context.Context, workspaceID, path string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	ws.LSPManager.Start(ctx, path)
+	return nil
+}
+
+// LSPStopAll stops all LSP servers for a workspace.
+func (b *Backend) LSPStopAll(ctx context.Context, workspaceID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	ws.LSPManager.StopAll(ctx)
+	return nil
+}
+
+// MCPGetStates returns the current state of all MCP clients.
+func (b *Backend) MCPGetStates(_ string) map[string]mcptools.ClientInfo {
+	return mcptools.GetStates()
+}
+
+// MCPRefreshPrompts refreshes prompts for a named MCP client.
+func (b *Backend) MCPRefreshPrompts(ctx context.Context, _ string, name string) {
+	mcptools.RefreshPrompts(ctx, name)
+}
+
+// MCPRefreshResources refreshes resources for a named MCP client.
+func (b *Backend) MCPRefreshResources(ctx context.Context, _ string, name string) {
+	mcptools.RefreshResources(ctx, name)
+}

internal/backend/filetracker.go 🔗

@@ -0,0 +1,37 @@
+package backend
+
+import (
+	"context"
+	"time"
+)
+
+// FileTrackerRecordRead records a file read for a session.
+func (b *Backend) FileTrackerRecordRead(ctx context.Context, workspaceID, sessionID, path string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	ws.FileTracker.RecordRead(ctx, sessionID, path)
+	return nil
+}
+
+// FileTrackerLastReadTime returns the last read time for a file in a session.
+func (b *Backend) FileTrackerLastReadTime(ctx context.Context, workspaceID, sessionID, path string) (time.Time, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return time.Time{}, err
+	}
+
+	return ws.FileTracker.LastReadTime(ctx, sessionID, path), nil
+}
+
+// FileTrackerListReadFiles returns the list of read files for a session.
+func (b *Backend) FileTrackerListReadFiles(ctx context.Context, workspaceID, sessionID string) ([]string, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	return ws.FileTracker.ListReadFiles(ctx, sessionID)
+}

internal/backend/permission.go 🔗

@@ -0,0 +1,59 @@
+package backend
+
+import (
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/proto"
+)
+
+// GrantPermission grants, denies, or persistently grants a permission
+// request.
+func (b *Backend) GrantPermission(workspaceID string, req proto.PermissionGrant) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	perm := permission.PermissionRequest{
+		ID:          req.Permission.ID,
+		SessionID:   req.Permission.SessionID,
+		ToolCallID:  req.Permission.ToolCallID,
+		ToolName:    req.Permission.ToolName,
+		Description: req.Permission.Description,
+		Action:      req.Permission.Action,
+		Params:      req.Permission.Params,
+		Path:        req.Permission.Path,
+	}
+
+	switch req.Action {
+	case proto.PermissionAllow:
+		ws.Permissions.Grant(perm)
+	case proto.PermissionAllowForSession:
+		ws.Permissions.GrantPersistent(perm)
+	case proto.PermissionDeny:
+		ws.Permissions.Deny(perm)
+	default:
+		return ErrInvalidPermissionAction
+	}
+	return nil
+}
+
+// SetPermissionsSkip sets whether permission prompts are skipped.
+func (b *Backend) SetPermissionsSkip(workspaceID string, skip bool) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	ws.Permissions.SetSkipRequests(skip)
+	return nil
+}
+
+// GetPermissionsSkip returns whether permission prompts are skipped.
+func (b *Backend) GetPermissionsSkip(workspaceID string) (bool, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return false, err
+	}
+
+	return ws.Permissions.SkipRequests(), nil
+}

internal/backend/session.go 🔗

@@ -0,0 +1,126 @@
+package backend
+
+import (
+	"context"
+
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/proto"
+	"github.com/charmbracelet/crush/internal/session"
+)
+
+// CreateSession creates a new session in the given workspace.
+func (b *Backend) CreateSession(ctx context.Context, workspaceID, title string) (session.Session, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return session.Session{}, err
+	}
+
+	return ws.Sessions.Create(ctx, title)
+}
+
+// GetSession retrieves a session by workspace and session ID.
+func (b *Backend) GetSession(ctx context.Context, workspaceID, sessionID string) (session.Session, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return session.Session{}, err
+	}
+
+	return ws.Sessions.Get(ctx, sessionID)
+}
+
+// ListSessions returns all sessions in the given workspace.
+func (b *Backend) ListSessions(ctx context.Context, workspaceID string) ([]session.Session, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	return ws.Sessions.List(ctx)
+}
+
+// GetAgentSession returns session metadata with the agent's busy
+// status.
+func (b *Backend) GetAgentSession(ctx context.Context, workspaceID, sessionID string) (proto.AgentSession, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return proto.AgentSession{}, err
+	}
+
+	se, err := ws.Sessions.Get(ctx, sessionID)
+	if err != nil {
+		return proto.AgentSession{}, err
+	}
+
+	var isSessionBusy bool
+	if ws.AgentCoordinator != nil {
+		isSessionBusy = ws.AgentCoordinator.IsSessionBusy(sessionID)
+	}
+
+	return proto.AgentSession{
+		Session: proto.Session{
+			ID:    se.ID,
+			Title: se.Title,
+		},
+		IsBusy: isSessionBusy,
+	}, nil
+}
+
+// ListSessionMessages returns all messages for a session.
+func (b *Backend) ListSessionMessages(ctx context.Context, workspaceID, sessionID string) ([]message.Message, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	return ws.Messages.List(ctx, sessionID)
+}
+
+// ListSessionHistory returns the history items for a session.
+func (b *Backend) ListSessionHistory(ctx context.Context, workspaceID, sessionID string) (any, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	return ws.History.ListBySession(ctx, sessionID)
+}
+
+// SaveSession updates a session in the given workspace.
+func (b *Backend) SaveSession(ctx context.Context, workspaceID string, sess session.Session) (session.Session, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return session.Session{}, err
+	}
+
+	return ws.Sessions.Save(ctx, sess)
+}
+
+// DeleteSession deletes a session from the given workspace.
+func (b *Backend) DeleteSession(ctx context.Context, workspaceID, sessionID string) error {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return err
+	}
+
+	return ws.Sessions.Delete(ctx, sessionID)
+}
+
+// ListUserMessages returns user-role messages for a session.
+func (b *Backend) ListUserMessages(ctx context.Context, workspaceID, sessionID string) ([]message.Message, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	return ws.Messages.ListUserMessages(ctx, sessionID)
+}
+
+// ListAllUserMessages returns all user-role messages across sessions.
+func (b *Backend) ListAllUserMessages(ctx context.Context, workspaceID string) ([]message.Message, error) {
+	ws, err := b.GetWorkspace(workspaceID)
+	if err != nil {
+		return nil, err
+	}
+
+	return ws.Messages.ListAllUserMessages(ctx)
+}

internal/backend/util.go 🔗

@@ -0,0 +1,22 @@
+package backend
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+func createDotCrushDir(dir string) error {
+	if err := os.MkdirAll(dir, 0o700); err != nil {
+		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
+	}
+
+	gitIgnorePath := filepath.Join(dir, ".gitignore")
+	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
+		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
+			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
+		}
+	}
+
+	return nil
+}

internal/client/client.go 🔗

@@ -0,0 +1,196 @@
+package client
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/url"
+	stdpath "path"
+	"path/filepath"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/proto"
+	"github.com/charmbracelet/crush/internal/server"
+)
+
+// DummyHost is used to satisfy the http.Client's requirement for a URL.
+const DummyHost = "api.crush.localhost"
+
+// Client represents an RPC client connected to a Crush server.
+type Client struct {
+	h       *http.Client
+	path    string
+	network string
+	addr    string
+}
+
+// DefaultClient creates a new [Client] connected to the default server address.
+func DefaultClient(path string) (*Client, error) {
+	host, err := server.ParseHostURL(server.DefaultHost())
+	if err != nil {
+		return nil, err
+	}
+	return NewClient(path, host.Scheme, host.Host)
+}
+
+// NewClient creates a new [Client] connected to the server at the given
+// network and address.
+func NewClient(path, network, address string) (*Client, error) {
+	c := new(Client)
+	c.path = filepath.Clean(path)
+	c.network = network
+	c.addr = address
+	p := &http.Protocols{}
+	p.SetHTTP1(true)
+	p.SetUnencryptedHTTP2(true)
+	tr := http.DefaultTransport.(*http.Transport).Clone()
+	tr.Protocols = p
+	tr.DialContext = c.dialer
+	if c.network == "npipe" || c.network == "unix" {
+		tr.DisableCompression = true
+	}
+	c.h = &http.Client{
+		Transport: tr,
+		Timeout:   0,
+	}
+	return c, nil
+}
+
+// Path returns the client's workspace filesystem path.
+func (c *Client) Path() string {
+	return c.path
+}
+
+// GetGlobalConfig retrieves the server's configuration.
+func (c *Client) GetGlobalConfig(ctx context.Context) (*config.Config, error) {
+	var cfg config.Config
+	rsp, err := c.get(ctx, "/config", nil, nil)
+	if err != nil {
+		return nil, err
+	}
+	defer rsp.Body.Close()
+	if err := json.NewDecoder(rsp.Body).Decode(&cfg); err != nil {
+		return nil, err
+	}
+	return &cfg, nil
+}
+
+// Health checks the server's health status.
+func (c *Client) Health(ctx context.Context) error {
+	rsp, err := c.get(ctx, "/health", nil, nil)
+	if err != nil {
+		return err
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("server health check failed: %s", rsp.Status)
+	}
+	return nil
+}
+
+// VersionInfo retrieves the server's version information.
+func (c *Client) VersionInfo(ctx context.Context) (*proto.VersionInfo, error) {
+	var vi proto.VersionInfo
+	rsp, err := c.get(ctx, "version", nil, nil)
+	if err != nil {
+		return nil, err
+	}
+	defer rsp.Body.Close()
+	if err := json.NewDecoder(rsp.Body).Decode(&vi); err != nil {
+		return nil, err
+	}
+	return &vi, nil
+}
+
+// ShutdownServer sends a shutdown request to the server.
+func (c *Client) ShutdownServer(ctx context.Context) error {
+	rsp, err := c.post(ctx, "/control", nil, jsonBody(proto.ServerControl{
+		Command: "shutdown",
+	}), nil)
+	if err != nil {
+		return err
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("server shutdown failed: %s", rsp.Status)
+	}
+	return nil
+}
+
+func (c *Client) dialer(ctx context.Context, network, address string) (net.Conn, error) {
+	d := net.Dialer{
+		Timeout:   30 * time.Second,
+		KeepAlive: 30 * time.Second,
+	}
+	switch c.network {
+	case "npipe":
+		ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+		defer cancel()
+		return dialPipeContext(ctx, c.addr)
+	case "unix":
+		return d.DialContext(ctx, "unix", c.addr)
+	default:
+		return d.DialContext(ctx, network, address)
+	}
+}
+
+func (c *Client) get(ctx context.Context, path string, query url.Values, headers http.Header) (*http.Response, error) {
+	return c.sendReq(ctx, http.MethodGet, path, query, nil, headers)
+}
+
+func (c *Client) post(ctx context.Context, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
+	return c.sendReq(ctx, http.MethodPost, path, query, body, headers)
+}
+
+func (c *Client) delete(ctx context.Context, path string, query url.Values, headers http.Header) (*http.Response, error) {
+	return c.sendReq(ctx, http.MethodDelete, path, query, nil, headers)
+}
+
+func (c *Client) put(ctx context.Context, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
+	return c.sendReq(ctx, http.MethodPut, path, query, body, headers)
+}
+
+func (c *Client) sendReq(ctx context.Context, method, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
+	url := (&url.URL{
+		Path:     stdpath.Join("/v1", path),
+		RawQuery: query.Encode(),
+	}).String()
+	req, err := c.buildReq(ctx, method, url, body, headers)
+	if err != nil {
+		return nil, err
+	}
+
+	rsp, err := c.h.Do(req)
+	if err != nil {
+		return nil, err
+	}
+
+	return rsp, nil
+}
+
+func (c *Client) buildReq(ctx context.Context, method, url string, body io.Reader, headers http.Header) (*http.Request, error) {
+	r, err := http.NewRequestWithContext(ctx, method, url, body)
+	if err != nil {
+		return nil, err
+	}
+
+	for k, v := range headers {
+		r.Header[http.CanonicalHeaderKey(k)] = v
+	}
+
+	r.URL.Scheme = "http"
+	r.URL.Host = c.addr
+	if c.network == "npipe" || c.network == "unix" {
+		r.Host = DummyHost
+	}
+
+	if body != nil && r.Header.Get("Content-Type") == "" {
+		r.Header.Set("Content-Type", "text/plain")
+	}
+
+	return r, nil
+}

internal/client/config.go 🔗

@@ -0,0 +1,278 @@
+package client
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/oauth"
+)
+
+// SetConfigField sets a config key/value pair on the server.
+func (c *Client) SetConfigField(ctx context.Context, id string, scope config.Scope, key string, value any) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/set", id), nil, jsonBody(struct {
+		Scope config.Scope `json:"scope"`
+		Key   string       `json:"key"`
+		Value any          `json:"value"`
+	}{Scope: scope, Key: key, Value: value}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to set config field: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to set config field: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// RemoveConfigField removes a config key on the server.
+func (c *Client) RemoveConfigField(ctx context.Context, id string, scope config.Scope, key string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/remove", id), nil, jsonBody(struct {
+		Scope config.Scope `json:"scope"`
+		Key   string       `json:"key"`
+	}{Scope: scope, Key: key}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to remove config field: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to remove config field: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// UpdatePreferredModel updates the preferred model on the server.
+func (c *Client) UpdatePreferredModel(ctx context.Context, id string, scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/model", id), nil, jsonBody(struct {
+		Scope     config.Scope             `json:"scope"`
+		ModelType config.SelectedModelType `json:"model_type"`
+		Model     config.SelectedModel     `json:"model"`
+	}{Scope: scope, ModelType: modelType, Model: model}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to update preferred model: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to update preferred model: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// SetCompactMode sets compact mode on the server.
+func (c *Client) SetCompactMode(ctx context.Context, id string, scope config.Scope, enabled bool) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/compact", id), nil, jsonBody(struct {
+		Scope   config.Scope `json:"scope"`
+		Enabled bool         `json:"enabled"`
+	}{Scope: scope, Enabled: enabled}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to set compact mode: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to set compact mode: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// SetProviderAPIKey sets a provider API key on the server.
+func (c *Client) SetProviderAPIKey(ctx context.Context, id string, scope config.Scope, providerID string, apiKey any) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/provider-key", id), nil, jsonBody(struct {
+		Scope      config.Scope `json:"scope"`
+		ProviderID string       `json:"provider_id"`
+		APIKey     any          `json:"api_key"`
+	}{Scope: scope, ProviderID: providerID, APIKey: apiKey}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to set provider API key: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to set provider API key: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// ImportCopilot attempts to import a GitHub Copilot token on the
+// server.
+func (c *Client) ImportCopilot(ctx context.Context, id string) (*oauth.Token, bool, error) {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/import-copilot", id), nil, nil, nil)
+	if err != nil {
+		return nil, false, fmt.Errorf("failed to import copilot: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, false, fmt.Errorf("failed to import copilot: status code %d", rsp.StatusCode)
+	}
+	var result struct {
+		Token   *oauth.Token `json:"token"`
+		Success bool         `json:"success"`
+	}
+	if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
+		return nil, false, fmt.Errorf("failed to decode import copilot response: %w", err)
+	}
+	return result.Token, result.Success, nil
+}
+
+// RefreshOAuthToken refreshes an OAuth token for a provider on the
+// server.
+func (c *Client) RefreshOAuthToken(ctx context.Context, id string, scope config.Scope, providerID string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/config/refresh-oauth", id), nil, jsonBody(struct {
+		Scope      config.Scope `json:"scope"`
+		ProviderID string       `json:"provider_id"`
+	}{Scope: scope, ProviderID: providerID}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to refresh OAuth token: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to refresh OAuth token: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// ProjectNeedsInitialization checks if the project needs
+// initialization.
+func (c *Client) ProjectNeedsInitialization(ctx context.Context, id string) (bool, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/project/needs-init", id), nil, nil)
+	if err != nil {
+		return false, fmt.Errorf("failed to check project init: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return false, fmt.Errorf("failed to check project init: status code %d", rsp.StatusCode)
+	}
+	var result struct {
+		NeedsInit bool `json:"needs_init"`
+	}
+	if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
+		return false, fmt.Errorf("failed to decode project init response: %w", err)
+	}
+	return result.NeedsInit, nil
+}
+
+// MarkProjectInitialized marks the project as initialized on the
+// server.
+func (c *Client) MarkProjectInitialized(ctx context.Context, id string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/project/init", id), nil, nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to mark project initialized: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to mark project initialized: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// GetInitializePrompt retrieves the initialization prompt from the
+// server.
+func (c *Client) GetInitializePrompt(ctx context.Context, id string) (string, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/project/init-prompt", id), nil, nil)
+	if err != nil {
+		return "", fmt.Errorf("failed to get init prompt: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("failed to get init prompt: status code %d", rsp.StatusCode)
+	}
+	var result struct {
+		Prompt string `json:"prompt"`
+	}
+	if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
+		return "", fmt.Errorf("failed to decode init prompt response: %w", err)
+	}
+	return result.Prompt, nil
+}
+
+// MCPResourceContents holds the contents of an MCP resource.
+type MCPResourceContents struct {
+	URI      string `json:"uri"`
+	MIMEType string `json:"mime_type,omitempty"`
+	Text     string `json:"text,omitempty"`
+	Blob     []byte `json:"blob,omitempty"`
+}
+
+// EnableDockerMCP enables the Docker MCP server on the workspace.
+func (c *Client) EnableDockerMCP(ctx context.Context, id string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/docker/enable", id), nil, nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to enable docker MCP: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to enable docker MCP: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// DisableDockerMCP disables the Docker MCP server on the workspace.
+func (c *Client) DisableDockerMCP(ctx context.Context, id string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/docker/disable", id), nil, nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to disable docker MCP: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to disable docker MCP: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// RefreshMCPTools refreshes tools for a named MCP server.
+func (c *Client) RefreshMCPTools(ctx context.Context, id, name string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-tools", id), nil, jsonBody(struct {
+		Name string `json:"name"`
+	}{Name: name}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to refresh MCP tools: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to refresh MCP tools: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// ReadMCPResource reads a resource from a named MCP server.
+func (c *Client) ReadMCPResource(ctx context.Context, id, name, uri string) ([]MCPResourceContents, error) {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/read-resource", id), nil, jsonBody(struct {
+		Name string `json:"name"`
+		URI  string `json:"uri"`
+	}{Name: name, URI: uri}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return nil, fmt.Errorf("failed to read MCP resource: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to read MCP resource: status code %d", rsp.StatusCode)
+	}
+	var contents []MCPResourceContents
+	if err := json.NewDecoder(rsp.Body).Decode(&contents); err != nil {
+		return nil, fmt.Errorf("failed to decode MCP resource: %w", err)
+	}
+	return contents, nil
+}
+
+// GetMCPPrompt retrieves a prompt from a named MCP server.
+func (c *Client) GetMCPPrompt(ctx context.Context, id, clientID, promptID string, args map[string]string) (string, error) {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/get-prompt", id), nil, jsonBody(struct {
+		ClientID string            `json:"client_id"`
+		PromptID string            `json:"prompt_id"`
+		Args     map[string]string `json:"args"`
+	}{ClientID: clientID, PromptID: promptID, Args: args}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return "", fmt.Errorf("failed to get MCP prompt: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("failed to get MCP prompt: status code %d", rsp.StatusCode)
+	}
+	var result struct {
+		Prompt string `json:"prompt"`
+	}
+	if err := json.NewDecoder(rsp.Body).Decode(&result); err != nil {
+		return "", fmt.Errorf("failed to decode MCP prompt response: %w", err)
+	}
+	return result.Prompt, nil
+}

internal/client/dial_other.go 🔗

@@ -0,0 +1,14 @@
+//go:build !windows
+// +build !windows
+
+package client
+
+import (
+	"context"
+	"net"
+	"syscall"
+)
+
+func dialPipeContext(context.Context, string) (net.Conn, error) {
+	return nil, syscall.EAFNOSUPPORT
+}

internal/client/dial_windows.go 🔗

@@ -0,0 +1,15 @@
+//go:build windows
+// +build windows
+
+package client
+
+import (
+	"context"
+	"net"
+
+	"github.com/Microsoft/go-winio"
+)
+
+func dialPipeContext(ctx context.Context, address string) (net.Conn, error) {
+	return winio.DialPipeContext(ctx, address)
+}

internal/client/proto.go 🔗

@@ -0,0 +1,750 @@
+package client
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/proto"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+)
+
+// ListWorkspaces retrieves all workspaces from the server.
+func (c *Client) ListWorkspaces(ctx context.Context) ([]proto.Workspace, error) {
+	rsp, err := c.get(ctx, "/workspaces", nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to list workspaces: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to list workspaces: status code %d", rsp.StatusCode)
+	}
+	var workspaces []proto.Workspace
+	if err := json.NewDecoder(rsp.Body).Decode(&workspaces); err != nil {
+		return nil, fmt.Errorf("failed to decode workspaces: %w", err)
+	}
+	return workspaces, nil
+}
+
+// CreateWorkspace creates a new workspace on the server.
+func (c *Client) CreateWorkspace(ctx context.Context, ws proto.Workspace) (*proto.Workspace, error) {
+	rsp, err := c.post(ctx, "/workspaces", nil, jsonBody(ws), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return nil, fmt.Errorf("failed to create workspace: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to create workspace: status code %d", rsp.StatusCode)
+	}
+	var created proto.Workspace
+	if err := json.NewDecoder(rsp.Body).Decode(&created); err != nil {
+		return nil, fmt.Errorf("failed to decode workspace: %w", err)
+	}
+	return &created, nil
+}
+
+// GetWorkspace retrieves a workspace from the server.
+func (c *Client) GetWorkspace(ctx context.Context, id string) (*proto.Workspace, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s", id), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get workspace: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get workspace: status code %d", rsp.StatusCode)
+	}
+	var ws proto.Workspace
+	if err := json.NewDecoder(rsp.Body).Decode(&ws); err != nil {
+		return nil, fmt.Errorf("failed to decode workspace: %w", err)
+	}
+	return &ws, nil
+}
+
+// DeleteWorkspace deletes a workspace on the server.
+func (c *Client) DeleteWorkspace(ctx context.Context, id string) error {
+	rsp, err := c.delete(ctx, fmt.Sprintf("/workspaces/%s", id), nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to delete workspace: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to delete workspace: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// SubscribeEvents subscribes to server-sent events for a workspace.
+func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, error) {
+	events := make(chan any, 100)
+	//nolint:bodyclose
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/events", id), nil, http.Header{
+		"Accept":        []string{"text/event-stream"},
+		"Cache-Control": []string{"no-cache"},
+		"Connection":    []string{"keep-alive"},
+	})
+	if err != nil {
+		return nil, fmt.Errorf("failed to subscribe to events: %w", err)
+	}
+
+	if rsp.StatusCode != http.StatusOK {
+		rsp.Body.Close()
+		return nil, fmt.Errorf("failed to subscribe to events: status code %d", rsp.StatusCode)
+	}
+
+	go func() {
+		defer rsp.Body.Close()
+
+		scr := bufio.NewReader(rsp.Body)
+		for {
+			line, err := scr.ReadBytes('\n')
+			if errors.Is(err, io.EOF) {
+				break
+			}
+			if err != nil {
+				slog.Error("Reading from events stream", "error", err)
+				time.Sleep(time.Second * 2)
+				continue
+			}
+			line = bytes.TrimSpace(line)
+			if len(line) == 0 {
+				continue
+			}
+
+			data, ok := bytes.CutPrefix(line, []byte("data:"))
+			if !ok {
+				slog.Warn("Invalid event format", "line", string(line))
+				continue
+			}
+
+			data = bytes.TrimSpace(data)
+
+			var p pubsub.Payload
+			if err := json.Unmarshal(data, &p); err != nil {
+				slog.Error("Unmarshaling event envelope", "error", err)
+				continue
+			}
+
+			switch p.Type {
+			case pubsub.PayloadTypeLSPEvent:
+				var e pubsub.Event[proto.LSPEvent]
+				_ = json.Unmarshal(p.Payload, &e)
+				sendEvent(ctx, events, e)
+			case pubsub.PayloadTypeMCPEvent:
+				var e pubsub.Event[proto.MCPEvent]
+				_ = json.Unmarshal(p.Payload, &e)
+				sendEvent(ctx, events, e)
+			case pubsub.PayloadTypePermissionRequest:
+				var e pubsub.Event[proto.PermissionRequest]
+				_ = json.Unmarshal(p.Payload, &e)
+				sendEvent(ctx, events, e)
+			case pubsub.PayloadTypePermissionNotification:
+				var e pubsub.Event[proto.PermissionNotification]
+				_ = json.Unmarshal(p.Payload, &e)
+				sendEvent(ctx, events, e)
+			case pubsub.PayloadTypeMessage:
+				var e pubsub.Event[proto.Message]
+				_ = json.Unmarshal(p.Payload, &e)
+				sendEvent(ctx, events, e)
+			case pubsub.PayloadTypeSession:
+				var e pubsub.Event[proto.Session]
+				_ = json.Unmarshal(p.Payload, &e)
+				sendEvent(ctx, events, e)
+			case pubsub.PayloadTypeFile:
+				var e pubsub.Event[proto.File]
+				_ = json.Unmarshal(p.Payload, &e)
+				sendEvent(ctx, events, e)
+			case pubsub.PayloadTypeAgentEvent:
+				var e pubsub.Event[proto.AgentEvent]
+				_ = json.Unmarshal(p.Payload, &e)
+				sendEvent(ctx, events, e)
+			default:
+				slog.Warn("Unknown event type", "type", p.Type)
+				continue
+			}
+		}
+	}()
+
+	return events, nil
+}
+
+func sendEvent(ctx context.Context, evc chan any, ev any) {
+	slog.Info("Event received", "event", fmt.Sprintf("%T %+v", ev, ev))
+	select {
+	case evc <- ev:
+	case <-ctx.Done():
+		close(evc)
+		return
+	}
+}
+
+// GetLSPDiagnostics retrieves LSP diagnostics for a specific LSP client.
+func (c *Client) GetLSPDiagnostics(ctx context.Context, id string, lspName string) (map[protocol.DocumentURI][]protocol.Diagnostic, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/lsps/%s/diagnostics", id, lspName), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get LSP diagnostics: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get LSP diagnostics: status code %d", rsp.StatusCode)
+	}
+	var diagnostics map[protocol.DocumentURI][]protocol.Diagnostic
+	if err := json.NewDecoder(rsp.Body).Decode(&diagnostics); err != nil {
+		return nil, fmt.Errorf("failed to decode LSP diagnostics: %w", err)
+	}
+	return diagnostics, nil
+}
+
+// GetLSPs retrieves the LSP client states for a workspace.
+func (c *Client) GetLSPs(ctx context.Context, id string) (map[string]proto.LSPClientInfo, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/lsps", id), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get LSPs: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get LSPs: status code %d", rsp.StatusCode)
+	}
+	var lsps map[string]proto.LSPClientInfo
+	if err := json.NewDecoder(rsp.Body).Decode(&lsps); err != nil {
+		return nil, fmt.Errorf("failed to decode LSPs: %w", err)
+	}
+	return lsps, nil
+}
+
+// MCPGetStates retrieves the MCP client states for a workspace.
+func (c *Client) MCPGetStates(ctx context.Context, id string) (map[string]proto.MCPClientInfo, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/mcp/states", id), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get MCP states: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get MCP states: status code %d", rsp.StatusCode)
+	}
+	var states map[string]proto.MCPClientInfo
+	if err := json.NewDecoder(rsp.Body).Decode(&states); err != nil {
+		return nil, fmt.Errorf("failed to decode MCP states: %w", err)
+	}
+	return states, nil
+}
+
+// MCPRefreshPrompts refreshes prompts for a named MCP client.
+func (c *Client) MCPRefreshPrompts(ctx context.Context, id, name string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-prompts", id), nil,
+		jsonBody(struct {
+			Name string `json:"name"`
+		}{Name: name}),
+		http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to refresh MCP prompts: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to refresh MCP prompts: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// MCPRefreshResources refreshes resources for a named MCP client.
+func (c *Client) MCPRefreshResources(ctx context.Context, id, name string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-resources", id), nil,
+		jsonBody(struct {
+			Name string `json:"name"`
+		}{Name: name}),
+		http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to refresh MCP resources: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to refresh MCP resources: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// GetAgentSessionQueuedPrompts retrieves the number of queued prompts for a
+// session.
+func (c *Client) GetAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) (int, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/queued", id, sessionID), nil, nil)
+	if err != nil {
+		return 0, fmt.Errorf("failed to get session agent queued prompts: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return 0, fmt.Errorf("failed to get session agent queued prompts: status code %d", rsp.StatusCode)
+	}
+	var count int
+	if err := json.NewDecoder(rsp.Body).Decode(&count); err != nil {
+		return 0, fmt.Errorf("failed to decode session agent queued prompts: %w", err)
+	}
+	return count, nil
+}
+
+// ClearAgentSessionQueuedPrompts clears the queued prompts for a session.
+func (c *Client) ClearAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/clear", id, sessionID), nil, nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to clear session agent queued prompts: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to clear session agent queued prompts: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// GetAgentInfo retrieves the agent status for a workspace.
+func (c *Client) GetAgentInfo(ctx context.Context, id string) (*proto.AgentInfo, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent", id), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get agent status: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get agent status: status code %d", rsp.StatusCode)
+	}
+	var info proto.AgentInfo
+	if err := json.NewDecoder(rsp.Body).Decode(&info); err != nil {
+		return nil, fmt.Errorf("failed to decode agent status: %w", err)
+	}
+	return &info, nil
+}
+
+// UpdateAgent triggers an agent model update on the server.
+func (c *Client) UpdateAgent(ctx context.Context, id string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/update", id), nil, nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to update agent: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to update agent: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// SendMessage sends a message to the agent for a workspace.
+func (c *Client) SendMessage(ctx context.Context, id string, sessionID, prompt string, attachments ...message.Attachment) error {
+	protoAttachments := make([]proto.Attachment, len(attachments))
+	for i, a := range attachments {
+		protoAttachments[i] = proto.Attachment{
+			FilePath: a.FilePath,
+			FileName: a.FileName,
+			MimeType: a.MimeType,
+			Content:  a.Content,
+		}
+	}
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent", id), nil, jsonBody(proto.AgentMessage{
+		SessionID:   sessionID,
+		Prompt:      prompt,
+		Attachments: protoAttachments,
+	}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to send message to agent: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to send message to agent: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// GetAgentSessionInfo retrieves the agent session info for a workspace.
+func (c *Client) GetAgentSessionInfo(ctx context.Context, id string, sessionID string) (*proto.AgentSession, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s", id, sessionID), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get session agent info: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get session agent info: status code %d", rsp.StatusCode)
+	}
+	var info proto.AgentSession
+	if err := json.NewDecoder(rsp.Body).Decode(&info); err != nil {
+		return nil, fmt.Errorf("failed to decode session agent info: %w", err)
+	}
+	return &info, nil
+}
+
+// AgentSummarizeSession requests a session summarization.
+func (c *Client) AgentSummarizeSession(ctx context.Context, id string, sessionID string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/summarize", id, sessionID), nil, nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to summarize session: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to summarize session: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// InitiateAgentProcessing triggers agent initialization on the server.
+func (c *Client) InitiateAgentProcessing(ctx context.Context, id string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/init", id), nil, nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to initiate session agent processing: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to initiate session agent processing: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// ListMessages retrieves all messages for a session as proto types.
+func (c *Client) ListMessages(ctx context.Context, id string, sessionID string) ([]proto.Message, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/messages", id, sessionID), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get messages: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get messages: status code %d", rsp.StatusCode)
+	}
+	var msgs []proto.Message
+	if err := json.NewDecoder(rsp.Body).Decode(&msgs); err != nil && !errors.Is(err, io.EOF) {
+		return nil, fmt.Errorf("failed to decode messages: %w", err)
+	}
+	return msgs, nil
+}
+
+// GetSession retrieves a specific session as a proto type.
+func (c *Client) GetSession(ctx context.Context, id string, sessionID string) (*proto.Session, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sessionID), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get session: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get session: status code %d", rsp.StatusCode)
+	}
+	var sess proto.Session
+	if err := json.NewDecoder(rsp.Body).Decode(&sess); err != nil {
+		return nil, fmt.Errorf("failed to decode session: %w", err)
+	}
+	return &sess, nil
+}
+
+// ListSessionHistoryFiles retrieves history files for a session as proto types.
+func (c *Client) ListSessionHistoryFiles(ctx context.Context, id string, sessionID string) ([]proto.File, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/history", id, sessionID), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get session history files: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get session history files: status code %d", rsp.StatusCode)
+	}
+	var files []proto.File
+	if err := json.NewDecoder(rsp.Body).Decode(&files); err != nil {
+		return nil, fmt.Errorf("failed to decode session history files: %w", err)
+	}
+	return files, nil
+}
+
+// CreateSession creates a new session in a workspace as a proto type.
+func (c *Client) CreateSession(ctx context.Context, id string, title string) (*proto.Session, error) {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/sessions", id), nil, jsonBody(proto.Session{Title: title}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return nil, fmt.Errorf("failed to create session: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to create session: status code %d", rsp.StatusCode)
+	}
+	var sess proto.Session
+	if err := json.NewDecoder(rsp.Body).Decode(&sess); err != nil {
+		return nil, fmt.Errorf("failed to decode session: %w", err)
+	}
+	return &sess, nil
+}
+
+// ListSessions lists all sessions in a workspace as proto types.
+func (c *Client) ListSessions(ctx context.Context, id string) ([]proto.Session, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions", id), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get sessions: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get sessions: status code %d", rsp.StatusCode)
+	}
+	var sessions []proto.Session
+	if err := json.NewDecoder(rsp.Body).Decode(&sessions); err != nil {
+		return nil, fmt.Errorf("failed to decode sessions: %w", err)
+	}
+	return sessions, nil
+}
+
+// GrantPermission grants a permission on a workspace.
+func (c *Client) GrantPermission(ctx context.Context, id string, req proto.PermissionGrant) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/permissions/grant", id), nil, jsonBody(req), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to grant permission: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to grant permission: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// SetPermissionsSkipRequests sets the skip-requests flag for a workspace.
+func (c *Client) SetPermissionsSkipRequests(ctx context.Context, id string, skip bool) error {
+	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"}})
+	if err != nil {
+		return fmt.Errorf("failed to set permissions skip requests: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to set permissions skip requests: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// GetPermissionsSkipRequests retrieves the skip-requests flag for a workspace.
+func (c *Client) GetPermissionsSkipRequests(ctx context.Context, id string) (bool, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/permissions/skip", id), nil, nil)
+	if err != nil {
+		return false, fmt.Errorf("failed to get permissions skip requests: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return false, fmt.Errorf("failed to get permissions skip requests: status code %d", rsp.StatusCode)
+	}
+	var skip proto.PermissionSkipRequest
+	if err := json.NewDecoder(rsp.Body).Decode(&skip); err != nil {
+		return false, fmt.Errorf("failed to decode permissions skip requests: %w", err)
+	}
+	return skip.Skip, nil
+}
+
+// GetConfig retrieves the workspace-specific configuration.
+func (c *Client) GetConfig(ctx context.Context, id string) (*config.Config, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/config", id), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get config: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get config: status code %d", rsp.StatusCode)
+	}
+	var cfg config.Config
+	if err := json.NewDecoder(rsp.Body).Decode(&cfg); err != nil {
+		return nil, fmt.Errorf("failed to decode config: %w", err)
+	}
+	return &cfg, nil
+}
+
+func jsonBody(v any) *bytes.Buffer {
+	b := new(bytes.Buffer)
+	m, _ := json.Marshal(v)
+	b.Write(m)
+	return b
+}
+
+// SaveSession updates a session in a workspace, returning a proto type.
+func (c *Client) SaveSession(ctx context.Context, id string, sess proto.Session) (*proto.Session, error) {
+	rsp, err := c.put(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sess.ID), nil, jsonBody(sess), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return nil, fmt.Errorf("failed to save session: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to save session: status code %d", rsp.StatusCode)
+	}
+	var saved proto.Session
+	if err := json.NewDecoder(rsp.Body).Decode(&saved); err != nil {
+		return nil, fmt.Errorf("failed to decode session: %w", err)
+	}
+	return &saved, nil
+}
+
+// DeleteSession deletes a session from a workspace.
+func (c *Client) DeleteSession(ctx context.Context, id string, sessionID string) error {
+	rsp, err := c.delete(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sessionID), nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to delete session: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to delete session: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// ListUserMessages retrieves user-role messages for a session as proto types.
+func (c *Client) ListUserMessages(ctx context.Context, id string, sessionID string) ([]proto.Message, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/messages/user", id, sessionID), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get user messages: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get user messages: status code %d", rsp.StatusCode)
+	}
+	var msgs []proto.Message
+	if err := json.NewDecoder(rsp.Body).Decode(&msgs); err != nil && !errors.Is(err, io.EOF) {
+		return nil, fmt.Errorf("failed to decode user messages: %w", err)
+	}
+	return msgs, nil
+}
+
+// ListAllUserMessages retrieves all user-role messages across sessions as proto types.
+func (c *Client) ListAllUserMessages(ctx context.Context, id string) ([]proto.Message, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/messages/user", id), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get all user messages: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get all user messages: status code %d", rsp.StatusCode)
+	}
+	var msgs []proto.Message
+	if err := json.NewDecoder(rsp.Body).Decode(&msgs); err != nil && !errors.Is(err, io.EOF) {
+		return nil, fmt.Errorf("failed to decode all user messages: %w", err)
+	}
+	return msgs, nil
+}
+
+// CancelAgentSession cancels an ongoing agent operation for a session.
+func (c *Client) CancelAgentSession(ctx context.Context, id string, sessionID string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/cancel", id, sessionID), nil, nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to cancel agent session: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to cancel agent session: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// GetAgentSessionQueuedPromptsList retrieves the list of queued prompt
+// strings for a session.
+func (c *Client) GetAgentSessionQueuedPromptsList(ctx context.Context, id string, sessionID string) ([]string, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/list", id, sessionID), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get queued prompts list: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get queued prompts list: status code %d", rsp.StatusCode)
+	}
+	var prompts []string
+	if err := json.NewDecoder(rsp.Body).Decode(&prompts); err != nil {
+		return nil, fmt.Errorf("failed to decode queued prompts list: %w", err)
+	}
+	return prompts, nil
+}
+
+// GetDefaultSmallModel retrieves the default small model for a provider.
+func (c *Client) GetDefaultSmallModel(ctx context.Context, id string, providerID string) (*config.SelectedModel, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/default-small-model", id), url.Values{"provider_id": []string{providerID}}, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get default small model: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get default small model: status code %d", rsp.StatusCode)
+	}
+	var model config.SelectedModel
+	if err := json.NewDecoder(rsp.Body).Decode(&model); err != nil {
+		return nil, fmt.Errorf("failed to decode default small model: %w", err)
+	}
+	return &model, nil
+}
+
+// FileTrackerRecordRead records a file read for a session.
+func (c *Client) FileTrackerRecordRead(ctx context.Context, id string, sessionID, path string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/filetracker/read", id), nil, jsonBody(struct {
+		SessionID string `json:"session_id"`
+		Path      string `json:"path"`
+	}{SessionID: sessionID, Path: path}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to record file read: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to record file read: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// FileTrackerLastReadTime returns the last read time for a file in a
+// session.
+func (c *Client) FileTrackerLastReadTime(ctx context.Context, id string, sessionID, path string) (time.Time, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/filetracker/lastread", id), url.Values{
+		"session_id": []string{sessionID},
+		"path":       []string{path},
+	}, nil)
+	if err != nil {
+		return time.Time{}, fmt.Errorf("failed to get last read time: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return time.Time{}, fmt.Errorf("failed to get last read time: status code %d", rsp.StatusCode)
+	}
+	var t time.Time
+	if err := json.NewDecoder(rsp.Body).Decode(&t); err != nil {
+		return time.Time{}, fmt.Errorf("failed to decode last read time: %w", err)
+	}
+	return t, nil
+}
+
+// FileTrackerListReadFiles returns the list of read files for a session.
+func (c *Client) FileTrackerListReadFiles(ctx context.Context, id string, sessionID string) ([]string, error) {
+	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/filetracker/files", id, sessionID), nil, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get read files: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("failed to get read files: status code %d", rsp.StatusCode)
+	}
+	var files []string
+	if err := json.NewDecoder(rsp.Body).Decode(&files); err != nil {
+		return nil, fmt.Errorf("failed to decode read files: %w", err)
+	}
+	return files, nil
+}
+
+// LSPStart starts an LSP server for a path.
+func (c *Client) LSPStart(ctx context.Context, id string, path string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/lsps/start", id), nil, jsonBody(struct {
+		Path string `json:"path"`
+	}{Path: path}), http.Header{"Content-Type": []string{"application/json"}})
+	if err != nil {
+		return fmt.Errorf("failed to start LSP: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to start LSP: status code %d", rsp.StatusCode)
+	}
+	return nil
+}
+
+// LSPStopAll stops all LSP servers for a workspace.
+func (c *Client) LSPStopAll(ctx context.Context, id string) error {
+	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/lsps/stop", id), nil, nil, nil)
+	if err != nil {
+		return fmt.Errorf("failed to stop LSPs: %w", err)
+	}
+	defer rsp.Body.Close()
+	if rsp.StatusCode != http.StatusOK {
+		return fmt.Errorf("failed to stop LSPs: status code %d", rsp.StatusCode)
+	}
+	return nil
+}

internal/cmd/login.go 🔗

@@ -10,10 +10,12 @@ import (
 	"charm.land/lipgloss/v2"
 	"github.com/atotto/clipboard"
 	hyperp "github.com/charmbracelet/crush/internal/agent/hyper"
+	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/oauth"
 	"github.com/charmbracelet/crush/internal/oauth/copilot"
 	"github.com/charmbracelet/crush/internal/oauth/hyper"
+	"github.com/charmbracelet/x/ansi"
 	"github.com/pkg/browser"
 	"github.com/spf13/cobra"
 )
@@ -40,11 +42,17 @@ crush login copilot
 	},
 	Args: cobra.MaximumNArgs(1),
 	RunE: func(cmd *cobra.Command, args []string) error {
-		app, err := setupAppWithProgressBar(cmd)
+		c, ws, cleanup, err := connectToServer(cmd)
 		if err != nil {
 			return err
 		}
-		defer app.Shutdown()
+		defer cleanup()
+
+		progressEnabled := ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
+		if progressEnabled && supportsProgressBar() {
+			_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
+			defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
+		}
 
 		provider := "hyper"
 		if len(args) > 0 {
@@ -52,16 +60,16 @@ crush login copilot
 		}
 		switch provider {
 		case "hyper":
-			return loginHyper(app.Store())
+			return loginHyper(c, ws.ID)
 		case "copilot", "github", "github-copilot":
-			return loginCopilot(app.Store())
+			return loginCopilot(cmd.Context(), c, ws.ID)
 		default:
 			return fmt.Errorf("unknown platform: %s", args[0])
 		}
 	},
 }
 
-func loginHyper(cfg *config.ConfigStore) error {
+func loginHyper(c *client.Client, wsID string) error {
 	if !hyperp.Enabled() {
 		return fmt.Errorf("hyper not enabled")
 	}
@@ -112,8 +120,8 @@ func loginHyper(cfg *config.ConfigStore) error {
 	}
 
 	if err := cmp.Or(
-		cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
-		cfg.SetConfigField(config.ScopeGlobal, "providers.hyper.oauth", token),
+		c.SetConfigField(ctx, wsID, config.ScopeGlobal, "providers.hyper.api_key", token.AccessToken),
+		c.SetConfigField(ctx, wsID, config.ScopeGlobal, "providers.hyper.oauth", token),
 	); err != nil {
 		return err
 	}
@@ -123,12 +131,15 @@ func loginHyper(cfg *config.ConfigStore) error {
 	return nil
 }
 
-func loginCopilot(cfg *config.ConfigStore) error {
-	ctx := getLoginContext()
+func loginCopilot(ctx context.Context, c *client.Client, wsID string) error {
+	loginCtx := getLoginContext()
 
-	if cfg.HasConfigField(config.ScopeGlobal, "providers.copilot.oauth") {
-		fmt.Println("You are already logged in to GitHub Copilot.")
-		return nil
+	cfg, err := c.GetConfig(ctx, wsID)
+	if err == nil && cfg != nil {
+		if pc, ok := cfg.Providers.Get("copilot"); ok && pc.OAuthToken != nil {
+			fmt.Println("You are already logged in to GitHub Copilot.")
+			return nil
+		}
 	}
 
 	diskToken, hasDiskToken := copilot.RefreshTokenFromDisk()
@@ -138,14 +149,14 @@ func loginCopilot(cfg *config.ConfigStore) error {
 	case hasDiskToken:
 		fmt.Println("Found existing GitHub Copilot token on disk. Using it to authenticate...")
 
-		t, err := copilot.RefreshToken(ctx, diskToken)
+		t, err := copilot.RefreshToken(loginCtx, diskToken)
 		if err != nil {
 			return fmt.Errorf("unable to refresh token from disk: %w", err)
 		}
 		token = t
 	default:
 		fmt.Println("Requesting device code from GitHub...")
-		dc, err := copilot.RequestDeviceCode(ctx)
+		dc, err := copilot.RequestDeviceCode(loginCtx)
 		if err != nil {
 			return err
 		}
@@ -159,7 +170,7 @@ func loginCopilot(cfg *config.ConfigStore) error {
 		fmt.Println()
 		fmt.Println("Waiting for authorization...")
 
-		t, err := copilot.PollForToken(ctx, dc)
+		t, err := copilot.PollForToken(loginCtx, dc)
 		if err == copilot.ErrNotAvailable {
 			fmt.Println()
 			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 {
 	}
 
 	if err := cmp.Or(
-		cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
-		cfg.SetConfigField(config.ScopeGlobal, "providers.copilot.oauth", token),
+		c.SetConfigField(loginCtx, wsID, config.ScopeGlobal, "providers.copilot.api_key", token.AccessToken),
+		c.SetConfigField(loginCtx, wsID, config.ScopeGlobal, "providers.copilot.oauth", token),
 	); err != nil {
 		return err
 	}

internal/cmd/root.go 🔗

@@ -7,24 +7,35 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"io/fs"
 	"log/slog"
+	"net/url"
 	"os"
+	"os/exec"
 	"path/filepath"
+	"regexp"
 	"strconv"
 	"strings"
+	"time"
 
 	tea "charm.land/bubbletea/v2"
-	"charm.land/fang/v2"
+	fang "charm.land/fang/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/colorprofile"
 	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/client"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/db"
 	"github.com/charmbracelet/crush/internal/event"
+	crushlog "github.com/charmbracelet/crush/internal/log"
 	"github.com/charmbracelet/crush/internal/projects"
+	"github.com/charmbracelet/crush/internal/proto"
+	"github.com/charmbracelet/crush/internal/server"
+	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	ui "github.com/charmbracelet/crush/internal/ui/model"
 	"github.com/charmbracelet/crush/internal/version"
+	"github.com/charmbracelet/crush/internal/workspace"
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
 	"github.com/charmbracelet/x/exp/charmtone"
@@ -32,10 +43,13 @@ import (
 	"github.com/spf13/cobra"
 )
 
+var clientHost string
+
 func init() {
 	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
+	rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
 	rootCmd.Flags().BoolP("help", "h", false, "Help")
 	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 	rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
@@ -88,15 +102,14 @@ crush --continue
 		sessionID, _ := cmd.Flags().GetString("session")
 		continueLast, _ := cmd.Flags().GetBool("continue")
 
-		app, err := setupAppWithProgressBar(cmd)
+		ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
 		if err != nil {
 			return err
 		}
-		defer app.Shutdown()
+		defer cleanup()
 
-		// Resolve session ID if provided
 		if sessionID != "" {
-			sess, err := resolveSessionID(cmd.Context(), app.Sessions, sessionID)
+			sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
 			if err != nil {
 				return err
 			}
@@ -105,19 +118,17 @@ crush --continue
 
 		event.AppInitialized()
 
-		// Set up the TUI.
-		var env uv.Environ = os.Environ()
-
-		com := common.DefaultCommon(app)
+		com := common.DefaultCommon(ws)
 		model := ui.New(com, sessionID, continueLast)
 
+		var env uv.Environ = os.Environ()
 		program := tea.NewProgram(
 			model,
 			tea.WithEnvironment(env),
 			tea.WithContext(cmd.Context()),
-			tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
+			tea.WithFilter(ui.MouseEventFilter),
 		)
-		go app.Subscribe(program)
+		go ws.Subscribe(program)
 
 		if _, err := program.Run(); err != nil {
 			event.Error(err)
@@ -147,6 +158,15 @@ const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{
 `
 
 func Execute() {
+	// FIXME: config.Load uses slog internally during provider resolution,
+	// but the file-based logger isn't set up until after config is loaded
+	// (because the log path depends on the data directory from config).
+	// This creates a window where slog calls in config.Load leak to
+	// stderr. We discard early logs here as a workaround. The proper
+	// fix is to remove slog calls from config.Load and have it return
+	// warnings/diagnostics instead of logging them as a side effect.
+	slog.SetDefault(slog.New(slog.DiscardHandler))
+
 	// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
 	// it forward to a bytes.Buffer, write the colored heartbit to it, and then
 	// finally prepend it in the version template.
@@ -183,25 +203,44 @@ func supportsProgressBar() bool {
 	return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
 }
 
-func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
-	app, err := setupApp(cmd)
-	if err != nil {
-		return nil, err
-	}
+// useClientServer returns true when the client/server architecture is
+// enabled via the CRUSH_CLIENT_SERVER environment variable.
+func useClientServer() bool {
+	v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
+	return v
+}
 
-	// Check if progress bar is enabled in config (defaults to true if nil)
-	progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
-	if progressEnabled && supportsProgressBar() {
+// setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
+// terminal progress bar shown during initialization.
+func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
+	showProgress := supportsProgressBar()
+	if showProgress {
 		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
-		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
 	}
 
-	return app, nil
+	ws, cleanup, err := setupWorkspace(cmd)
+
+	if showProgress {
+		_, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
+	}
+
+	return ws, cleanup, err
+}
+
+// setupWorkspace returns a Workspace and cleanup function. When
+// CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
+// ClientWorkspace. Otherwise it creates an in-process app.App and
+// returns an AppWorkspace.
+func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
+	if useClientServer() {
+		return setupClientServerWorkspace(cmd)
+	}
+	return setupLocalWorkspace(cmd)
 }
 
-// setupApp handles the common setup logic for both interactive and non-interactive modes.
-// It returns the app instance, config, cleanup function, and any error.
-func setupApp(cmd *cobra.Command) (*app.App, error) {
+// setupLocalWorkspace creates an in-process app.App and wraps it in an
+// AppWorkspace.
+func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
 	debug, _ := cmd.Flags().GetBool("debug")
 	yolo, _ := cmd.Flags().GetBool("yolo")
 	dataDir, _ := cmd.Flags().GetString("data-dir")
@@ -209,47 +248,270 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
 
 	cwd, err := ResolveCwd(cmd)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	store, err := config.Init(cwd, dataDir, debug)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	cfg := store.Config()
-	if cfg.Permissions == nil {
-		cfg.Permissions = &config.Permissions{}
+	store.Overrides().SkipPermissionRequests = yolo
+
+	if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
+		return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
 	}
-	cfg.Permissions.SkipRequests = yolo
 
-	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
-		return nil, err
+	gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
+	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
+		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
+			return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
+		}
 	}
 
-	// Register this project in the centralized projects list.
 	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
 		slog.Warn("Failed to register project", "error", err)
-		// Non-fatal: continue even if registration fails
 	}
 
-	// Connect to DB; this will also run migrations.
 	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
+	logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
+	crushlog.Setup(logFile, debug)
+
 	appInstance, err := app.New(ctx, conn, store)
 	if err != nil {
+		_ = conn.Close()
 		slog.Error("Failed to create app instance", "error", err)
-		return nil, err
+		return nil, nil, err
 	}
 
 	if shouldEnableMetrics(cfg) {
 		event.Init()
 	}
 
-	return appInstance, nil
+	ws := workspace.NewAppWorkspace(appInstance, store)
+	cleanup := func() { appInstance.Shutdown() }
+	return ws, cleanup, nil
+}
+
+// setupClientServerWorkspace connects to a server process and wraps the
+// result in a ClientWorkspace.
+func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
+	c, protoWs, cleanupServer, err := connectToServer(cmd)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	clientWs := workspace.NewClientWorkspace(c, *protoWs)
+
+	if protoWs.Config.IsConfigured() {
+		if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
+			slog.Error("Failed to initialize coder agent", "error", err)
+		}
+	}
+
+	return clientWs, cleanupServer, nil
+}
+
+// connectToServer ensures the server is running, creates a client and
+// workspace, and returns a cleanup function that deletes the workspace.
+func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
+	hostURL, err := server.ParseHostURL(clientHost)
+	if err != nil {
+		return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
+	}
+
+	if err := ensureServer(cmd, hostURL); err != nil {
+		return nil, nil, nil, err
+	}
+
+	debug, _ := cmd.Flags().GetBool("debug")
+	yolo, _ := cmd.Flags().GetBool("yolo")
+	dataDir, _ := cmd.Flags().GetString("data-dir")
+	ctx := cmd.Context()
+
+	cwd, err := ResolveCwd(cmd)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
+	if err != nil {
+		return nil, nil, nil, err
+	}
+
+	wsReq := proto.Workspace{
+		Path:    cwd,
+		DataDir: dataDir,
+		Debug:   debug,
+		YOLO:    yolo,
+		Version: version.Version,
+		Env:     os.Environ(),
+	}
+
+	ws, err := c.CreateWorkspace(ctx, wsReq)
+	if err != nil {
+		// The server socket may exist before the HTTP handler is ready.
+		// Retry a few times with a short backoff.
+		for range 5 {
+			select {
+			case <-ctx.Done():
+				return nil, nil, nil, ctx.Err()
+			case <-time.After(200 * time.Millisecond):
+			}
+			ws, err = c.CreateWorkspace(ctx, wsReq)
+			if err == nil {
+				break
+			}
+		}
+		if err != nil {
+			return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
+		}
+	}
+
+	if shouldEnableMetrics(ws.Config) {
+		event.Init()
+	}
+
+	if ws.Config != nil {
+		logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
+		crushlog.Setup(logFile, debug)
+	}
+
+	cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
+	return c, ws, cleanup, nil
+}
+
+// ensureServer auto-starts a detached server if the socket file does not
+// exist. When the socket exists, it verifies that the running server
+// version matches the client; on mismatch it shuts down the old server
+// and starts a fresh one.
+func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
+	switch hostURL.Scheme {
+	case "unix", "npipe":
+		needsStart := false
+		if _, err := os.Stat(hostURL.Host); err != nil && errors.Is(err, fs.ErrNotExist) {
+			needsStart = true
+		} else if err == nil {
+			if err := restartIfStale(cmd, hostURL); err != nil {
+				slog.Warn("Failed to check server version, restarting", "error", err)
+				needsStart = true
+			}
+		}
+
+		if needsStart {
+			if err := startDetachedServer(cmd); err != nil {
+				return err
+			}
+		}
+
+		var err error
+		for range 10 {
+			_, err = os.Stat(hostURL.Host)
+			if err == nil {
+				break
+			}
+			select {
+			case <-cmd.Context().Done():
+				return cmd.Context().Err()
+			case <-time.After(100 * time.Millisecond):
+			}
+		}
+		if err != nil {
+			return fmt.Errorf("failed to initialize crush server: %v", err)
+		}
+	}
+
+	return nil
+}
+
+// restartIfStale checks whether the running server matches the current
+// client version. When they differ, it sends a shutdown command and
+// removes the stale socket so the caller can start a fresh server.
+func restartIfStale(cmd *cobra.Command, hostURL *url.URL) error {
+	c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
+	if err != nil {
+		return err
+	}
+	vi, err := c.VersionInfo(cmd.Context())
+	if err != nil {
+		return err
+	}
+	if vi.Version == version.Version {
+		return nil
+	}
+	slog.Info("Server version mismatch, restarting",
+		"server", vi.Version,
+		"client", version.Version,
+	)
+	_ = c.ShutdownServer(cmd.Context())
+	// Give the old process a moment to release the socket.
+	for range 20 {
+		if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
+			break
+		}
+		select {
+		case <-cmd.Context().Done():
+			return cmd.Context().Err()
+		case <-time.After(100 * time.Millisecond):
+		}
+	}
+	// Force-remove if the socket is still lingering.
+	_ = os.Remove(hostURL.Host)
+	return nil
+}
+
+var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
+
+func startDetachedServer(cmd *cobra.Command) error {
+	exe, err := os.Executable()
+	if err != nil {
+		return fmt.Errorf("failed to get executable path: %v", err)
+	}
+
+	safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
+	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
+	if err := os.MkdirAll(chDir, 0o700); err != nil {
+		return fmt.Errorf("failed to create server working directory: %v", err)
+	}
+
+	cmdArgs := []string{"server"}
+	if clientHost != server.DefaultHost() {
+		cmdArgs = append(cmdArgs, "--host", clientHost)
+	}
+
+	c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
+	stdoutPath := filepath.Join(chDir, "stdout.log")
+	stderrPath := filepath.Join(chDir, "stderr.log")
+	detachProcess(c)
+
+	stdout, err := os.Create(stdoutPath)
+	if err != nil {
+		return fmt.Errorf("failed to create stdout log file: %v", err)
+	}
+	defer stdout.Close()
+	c.Stdout = stdout
+
+	stderr, err := os.Create(stderrPath)
+	if err != nil {
+		return fmt.Errorf("failed to create stderr log file: %v", err)
+	}
+	defer stderr.Close()
+	c.Stderr = stderr
+
+	if err := c.Start(); err != nil {
+		return fmt.Errorf("failed to start crush server: %v", err)
+	}
+
+	if err := c.Process.Release(); err != nil {
+		return fmt.Errorf("failed to detach crush server process: %v", err)
+	}
+
+	return nil
 }
 
 func shouldEnableMetrics(cfg *config.Config) bool {
@@ -284,6 +546,38 @@ func MaybePrependStdin(prompt string) (string, error) {
 	return string(bts) + "\n\n" + prompt, nil
 }
 
+// resolveWorkspaceSessionID resolves a session ID that may be a full
+// UUID, full hash, or hash prefix. Works against the Workspace
+// interface so both local and client/server paths get hash prefix
+// support.
+func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
+	if sess, err := ws.GetSession(ctx, id); err == nil {
+		return sess, nil
+	}
+
+	sessions, err := ws.ListSessions(ctx)
+	if err != nil {
+		return session.Session{}, err
+	}
+
+	var matches []session.Session
+	for _, s := range sessions {
+		hash := session.HashID(s.ID)
+		if hash == id || strings.HasPrefix(hash, id) {
+			matches = append(matches, s)
+		}
+	}
+
+	switch len(matches) {
+	case 0:
+		return session.Session{}, fmt.Errorf("session not found: %s", id)
+	case 1:
+		return matches[0], nil
+	default:
+		return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
+	}
+}
+
 func ResolveCwd(cmd *cobra.Command) (string, error) {
 	cwd, _ := cmd.Flags().GetString("cwd")
 	if cwd != "" {

internal/cmd/root_other.go 🔗

@@ -0,0 +1,16 @@
+//go:build !windows
+// +build !windows
+
+package cmd
+
+import (
+	"os/exec"
+	"syscall"
+)
+
+func detachProcess(c *exec.Cmd) {
+	if c.SysProcAttr == nil {
+		c.SysProcAttr = &syscall.SysProcAttr{}
+	}
+	c.SysProcAttr.Setsid = true
+}

internal/cmd/root_windows.go 🔗

@@ -0,0 +1,18 @@
+//go:build windows
+// +build windows
+
+package cmd
+
+import (
+	"os/exec"
+	"syscall"
+
+	"golang.org/x/sys/windows"
+)
+
+func detachProcess(c *exec.Cmd) {
+	if c.SysProcAttr == nil {
+		c.SysProcAttr = &syscall.SysProcAttr{}
+	}
+	c.SysProcAttr.CreationFlags = syscall.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS
+}

internal/cmd/run.go 🔗

@@ -7,9 +7,23 @@ import (
 	"os"
 	"os/signal"
 	"strings"
+	"time"
 
+	"charm.land/lipgloss/v2"
 	"charm.land/log/v2"
+	"github.com/charmbracelet/crush/internal/client"
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/event"
+	"github.com/charmbracelet/crush/internal/format"
+	"github.com/charmbracelet/crush/internal/proto"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/crush/internal/workspace"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/charmbracelet/x/exp/charmtone"
+	"github.com/charmbracelet/x/term"
 	"github.com/spf13/cobra"
 )
 
@@ -59,31 +73,9 @@ crush run --continue "Follow up on your last response"
 		ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
 		defer cancel()
 
-		app, err := setupApp(cmd)
-		if err != nil {
-			return err
-		}
-		defer app.Shutdown()
-
-		if sessionID != "" {
-			sess, err := resolveSessionID(ctx, app.Sessions, sessionID)
-			if err != nil {
-				return err
-			}
-			sessionID = sess.ID
-		}
-
-		if !app.Config().IsConfigured() {
-			return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
-		}
-
-		if verbose {
-			slog.SetDefault(slog.New(log.New(os.Stderr)))
-		}
-
 		prompt := strings.Join(args, " ")
 
-		prompt, err = MaybePrependStdin(prompt)
+		prompt, err := MaybePrependStdin(prompt)
 		if err != nil {
 			slog.Error("Failed to read from stdin", "error", err)
 			return err
@@ -103,7 +95,48 @@ crush run --continue "Follow up on your last response"
 			event.SetContinueLastSession(true)
 		}
 
-		return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
+		if useClientServer() {
+			c, ws, cleanup, err := connectToServer(cmd)
+			if err != nil {
+				return err
+			}
+			defer cleanup()
+
+			if sessionID != "" {
+				sess, err := resolveSessionByID(ctx, c, ws.ID, sessionID)
+				if err != nil {
+					return err
+				}
+				sessionID = sess.ID
+			}
+
+			if !ws.Config.IsConfigured() {
+				return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
+			}
+
+			if verbose {
+				slog.SetDefault(slog.New(log.New(os.Stderr)))
+			}
+
+			return runNonInteractive(ctx, c, ws, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
+		}
+
+		ws, cleanup, err := setupLocalWorkspace(cmd)
+		if err != nil {
+			return err
+		}
+		defer cleanup()
+
+		if !ws.Config().IsConfigured() {
+			return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
+		}
+
+		if verbose {
+			slog.SetDefault(slog.New(log.New(os.Stderr)))
+		}
+
+		appWs := ws.(*workspace.AppWorkspace)
+		return appWs.App().RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
 	},
 }
 
@@ -116,3 +149,382 @@ func init() {
 	runCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
 	runCmd.MarkFlagsMutuallyExclusive("session", "continue")
 }
+
+// runNonInteractive executes the agent via the server and streams output
+// to stdout.
+func runNonInteractive(
+	ctx context.Context,
+	c *client.Client,
+	ws *proto.Workspace,
+	prompt, largeModel, smallModel string,
+	hideSpinner bool,
+	continueSessionID string,
+	useLast bool,
+) error {
+	slog.Info("Running in non-interactive mode")
+
+	ctx, cancel := context.WithCancel(ctx)
+	defer cancel()
+
+	if largeModel != "" || smallModel != "" {
+		if err := overrideModels(ctx, c, ws, largeModel, smallModel); err != nil {
+			return fmt.Errorf("failed to override models: %w", err)
+		}
+	}
+
+	var (
+		spinner   *format.Spinner
+		stdoutTTY bool
+		stderrTTY bool
+		stdinTTY  bool
+		progress  bool
+	)
+
+	stdoutTTY = term.IsTerminal(os.Stdout.Fd())
+	stderrTTY = term.IsTerminal(os.Stderr.Fd())
+	stdinTTY = term.IsTerminal(os.Stdin.Fd())
+	progress = ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
+
+	if !hideSpinner && stderrTTY {
+		t := styles.DefaultStyles()
+
+		hasDarkBG := true
+		if stdinTTY && stdoutTTY {
+			hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
+		}
+		defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
+
+		spinner = format.NewSpinner(ctx, cancel, anim.Settings{
+			Size:        10,
+			Label:       "Generating",
+			LabelColor:  defaultFG,
+			GradColorA:  t.Primary,
+			GradColorB:  t.Secondary,
+			CycleColors: true,
+		})
+		spinner.Start()
+	}
+
+	stopSpinner := func() {
+		if !hideSpinner && spinner != nil {
+			spinner.Stop()
+			spinner = nil
+		}
+	}
+
+	// Wait for the agent to become ready (MCP init, etc).
+	if err := waitForAgent(ctx, c, ws.ID); err != nil {
+		stopSpinner()
+		return fmt.Errorf("agent not ready: %w", err)
+	}
+
+	// Force-update agent models so MCP tools are loaded.
+	if err := c.UpdateAgent(ctx, ws.ID); err != nil {
+		slog.Warn("Failed to update agent", "error", err)
+	}
+
+	defer stopSpinner()
+
+	sess, err := resolveSession(ctx, c, ws.ID, continueSessionID, useLast)
+	if err != nil {
+		return fmt.Errorf("failed to resolve session: %w", err)
+	}
+	if continueSessionID != "" || useLast {
+		slog.Info("Continuing session for non-interactive run", "session_id", sess.ID)
+	} else {
+		slog.Info("Created session for non-interactive run", "session_id", sess.ID)
+	}
+
+	events, err := c.SubscribeEvents(ctx, ws.ID)
+	if err != nil {
+		return fmt.Errorf("failed to subscribe to events: %w", err)
+	}
+
+	if err := c.SendMessage(ctx, ws.ID, sess.ID, prompt); err != nil {
+		return fmt.Errorf("failed to send message: %w", err)
+	}
+
+	messageReadBytes := make(map[string]int)
+	var printed bool
+
+	defer func() {
+		if progress && stderrTTY {
+			_, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
+		}
+		_, _ = fmt.Fprintln(os.Stdout)
+	}()
+
+	for {
+		if progress && stderrTTY {
+			_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
+		}
+
+		select {
+		case ev, ok := <-events:
+			if !ok {
+				stopSpinner()
+				return nil
+			}
+
+			switch e := ev.(type) {
+			case pubsub.Event[proto.Message]:
+				msg := e.Payload
+				if msg.SessionID != sess.ID || msg.Role != proto.Assistant || len(msg.Parts) == 0 {
+					continue
+				}
+				stopSpinner()
+
+				content := msg.Content().String()
+				readBytes := messageReadBytes[msg.ID]
+
+				if len(content) < readBytes {
+					slog.Error("Non-interactive: message content shorter than read bytes",
+						"message_length", len(content), "read_bytes", readBytes)
+					return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(content), readBytes)
+				}
+
+				part := content[readBytes:]
+				if readBytes == 0 {
+					part = strings.TrimLeft(part, " \t")
+				}
+				if printed || strings.TrimSpace(part) != "" {
+					printed = true
+					fmt.Fprint(os.Stdout, part)
+				}
+				messageReadBytes[msg.ID] = len(content)
+
+				if msg.IsFinished() {
+					return nil
+				}
+
+			case pubsub.Event[proto.AgentEvent]:
+				if e.Payload.Error != nil {
+					stopSpinner()
+					return fmt.Errorf("agent error: %w", e.Payload.Error)
+				}
+			}
+
+		case <-ctx.Done():
+			stopSpinner()
+			return ctx.Err()
+		}
+	}
+}
+
+// waitForAgent polls GetAgentInfo until the agent is ready, with a
+// timeout.
+func waitForAgent(ctx context.Context, c *client.Client, wsID string) error {
+	timeout := time.After(30 * time.Second)
+	for {
+		info, err := c.GetAgentInfo(ctx, wsID)
+		if err == nil && info.IsReady {
+			return nil
+		}
+		select {
+		case <-timeout:
+			if err != nil {
+				return fmt.Errorf("timeout waiting for agent: %w", err)
+			}
+			return fmt.Errorf("timeout waiting for agent readiness")
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-time.After(200 * time.Millisecond):
+		}
+	}
+}
+
+// overrideModels resolves model strings and updates the workspace
+// configuration via the server.
+func overrideModels(
+	ctx context.Context,
+	c *client.Client,
+	ws *proto.Workspace,
+	largeModel, smallModel string,
+) error {
+	cfg, err := c.GetConfig(ctx, ws.ID)
+	if err != nil {
+		return fmt.Errorf("failed to get config: %w", err)
+	}
+
+	providers := cfg.Providers.Copy()
+
+	largeMatches, smallMatches := findModelMatches(providers, largeModel, smallModel)
+
+	var largeProviderID string
+
+	if largeModel != "" {
+		found, err := validateModelMatches(largeMatches, largeModel, "large")
+		if err != nil {
+			return err
+		}
+		largeProviderID = found.provider
+		slog.Info("Overriding large model", "provider", found.provider, "model", found.modelID)
+		if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeLarge, config.SelectedModel{
+			Provider: found.provider,
+			Model:    found.modelID,
+		}); err != nil {
+			return fmt.Errorf("failed to set large model: %w", err)
+		}
+	}
+
+	switch {
+	case smallModel != "":
+		found, err := validateModelMatches(smallMatches, smallModel, "small")
+		if err != nil {
+			return err
+		}
+		slog.Info("Overriding small model", "provider", found.provider, "model", found.modelID)
+		if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeSmall, config.SelectedModel{
+			Provider: found.provider,
+			Model:    found.modelID,
+		}); err != nil {
+			return fmt.Errorf("failed to set small model: %w", err)
+		}
+
+	case largeModel != "":
+		sm, err := c.GetDefaultSmallModel(ctx, ws.ID, largeProviderID)
+		if err != nil {
+			slog.Warn("Failed to get default small model", "error", err)
+		} else if sm != nil {
+			if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeSmall, *sm); err != nil {
+				return fmt.Errorf("failed to set small model: %w", err)
+			}
+		}
+	}
+
+	return c.UpdateAgent(ctx, ws.ID)
+}
+
+type modelMatch struct {
+	provider string
+	modelID  string
+}
+
+// findModelMatches searches providers for matching large/small model
+// strings.
+func findModelMatches(providers map[string]config.ProviderConfig, largeModel, smallModel string) ([]modelMatch, []modelMatch) {
+	largeFilter, largeID := parseModelString(largeModel)
+	smallFilter, smallID := parseModelString(smallModel)
+
+	var largeMatches, smallMatches []modelMatch
+	for name, provider := range providers {
+		if provider.Disable {
+			continue
+		}
+		for _, m := range provider.Models {
+			if matchesModel(largeID, largeFilter, m.ID, name) {
+				largeMatches = append(largeMatches, modelMatch{provider: name, modelID: m.ID})
+			}
+			if matchesModel(smallID, smallFilter, m.ID, name) {
+				smallMatches = append(smallMatches, modelMatch{provider: name, modelID: m.ID})
+			}
+		}
+	}
+	return largeMatches, smallMatches
+}
+
+// parseModelString splits "provider/model" into (provider, model) or
+// ("", model).
+func parseModelString(s string) (string, string) {
+	if s == "" {
+		return "", ""
+	}
+	if idx := strings.Index(s, "/"); idx >= 0 {
+		return s[:idx], s[idx+1:]
+	}
+	return "", s
+}
+
+// matchesModel returns true if the model ID matches the filter
+// criteria.
+func matchesModel(wantID, wantProvider, modelID, providerName string) bool {
+	if wantID == "" {
+		return false
+	}
+	if wantProvider != "" && wantProvider != providerName {
+		return false
+	}
+	return strings.EqualFold(modelID, wantID)
+}
+
+// validateModelMatches ensures exactly one match exists.
+func validateModelMatches(matches []modelMatch, modelID, label string) (modelMatch, error) {
+	switch {
+	case len(matches) == 0:
+		return modelMatch{}, fmt.Errorf("%s model %q not found", label, modelID)
+	case len(matches) > 1:
+		names := make([]string, len(matches))
+		for i, m := range matches {
+			names[i] = m.provider
+		}
+		return modelMatch{}, fmt.Errorf(
+			"%s model: model %q found in multiple providers: %s. Please specify provider using 'provider/model' format",
+			label, modelID, strings.Join(names, ", "),
+		)
+	}
+	return matches[0], nil
+}
+
+// resolveSession returns the session to use for a non-interactive run.
+// If continueSessionID is set it fetches that session; if useLast is set it
+// returns the most recently updated top-level session; otherwise it creates a
+// new one.
+func resolveSession(ctx context.Context, c *client.Client, wsID, continueSessionID string, useLast bool) (*proto.Session, error) {
+	switch {
+	case continueSessionID != "":
+		sess, err := c.GetSession(ctx, wsID, continueSessionID)
+		if err != nil {
+			return nil, fmt.Errorf("session not found: %s", continueSessionID)
+		}
+		if sess.ParentSessionID != "" {
+			return nil, fmt.Errorf("cannot continue a child session: %s", continueSessionID)
+		}
+		return sess, nil
+
+	case useLast:
+		sessions, err := c.ListSessions(ctx, wsID)
+		if err != nil || len(sessions) == 0 {
+			return nil, fmt.Errorf("no sessions found to continue")
+		}
+		last := sessions[0]
+		for _, s := range sessions[1:] {
+			if s.UpdatedAt > last.UpdatedAt && s.ParentSessionID == "" {
+				last = s
+			}
+		}
+		return &last, nil
+
+	default:
+		return c.CreateSession(ctx, wsID, "non-interactive")
+	}
+}
+
+// resolveSessionByID resolves a session ID that may be a full UUID or a hash
+// prefix returned by crush session list.
+func resolveSessionByID(ctx context.Context, c *client.Client, wsID, id string) (*proto.Session, error) {
+	if sess, err := c.GetSession(ctx, wsID, id); err == nil {
+		return sess, nil
+	}
+
+	sessions, err := c.ListSessions(ctx, wsID)
+	if err != nil {
+		return nil, err
+	}
+
+	var matches []proto.Session
+	for _, s := range sessions {
+		hash := session.HashID(s.ID)
+		if hash == id || strings.HasPrefix(hash, id) {
+			matches = append(matches, s)
+		}
+	}
+
+	switch len(matches) {
+	case 0:
+		return nil, fmt.Errorf("session %q not found", id)
+	case 1:
+		return &matches[0], nil
+	default:
+		return nil, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
+	}
+}

internal/cmd/server.go 🔗

@@ -0,0 +1,99 @@
+package cmd
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log/slog"
+	"os"
+	"os/signal"
+	"path/filepath"
+	"time"
+
+	"github.com/charmbracelet/crush/internal/config"
+	crushlog "github.com/charmbracelet/crush/internal/log"
+	"github.com/charmbracelet/crush/internal/server"
+	"github.com/charmbracelet/x/term"
+	"github.com/spf13/cobra"
+)
+
+var serverHost string
+
+func init() {
+	serverCmd.Flags().StringVarP(&serverHost, "host", "H", server.DefaultHost(), "Server host (TCP or Unix socket)")
+	rootCmd.AddCommand(serverCmd)
+}
+
+var serverCmd = &cobra.Command{
+	Use:   "server",
+	Short: "Start the Crush server",
+	RunE: func(cmd *cobra.Command, _ []string) error {
+		dataDir, err := cmd.Flags().GetString("data-dir")
+		if err != nil {
+			return fmt.Errorf("failed to get data directory: %v", err)
+		}
+		debug, err := cmd.Flags().GetBool("debug")
+		if err != nil {
+			return fmt.Errorf("failed to get debug flag: %v", err)
+		}
+
+		cfg, err := config.Load(config.GlobalWorkspaceDir(), dataDir, debug)
+		if err != nil {
+			return fmt.Errorf("failed to load configuration: %v", err)
+		}
+
+		logFile := filepath.Join(config.GlobalCacheDir(), "server-"+safeNameRegexp.ReplaceAllString(serverHost, "_"), "crush.log")
+
+		if term.IsTerminal(os.Stderr.Fd()) {
+			crushlog.Setup(logFile, debug, os.Stderr)
+		} else {
+			crushlog.Setup(logFile, debug)
+		}
+
+		hostURL, err := server.ParseHostURL(serverHost)
+		if err != nil {
+			return fmt.Errorf("invalid server host: %v", err)
+		}
+
+		srv := server.NewServer(cfg, hostURL.Scheme, hostURL.Host)
+		srv.SetLogger(slog.Default())
+		slog.Info("Starting Crush server...", "addr", serverHost)
+
+		errch := make(chan error, 1)
+		sigch := make(chan os.Signal, 1)
+		sigs := []os.Signal{os.Interrupt}
+		sigs = append(sigs, addSignals(sigs)...)
+		signal.Notify(sigch, sigs...)
+
+		go func() {
+			errch <- srv.ListenAndServe()
+		}()
+
+		select {
+		case <-sigch:
+			slog.Info("Received interrupt signal...")
+		case err = <-errch:
+			if err != nil && !errors.Is(err, server.ErrServerClosed) {
+				_ = srv.Close()
+				slog.Error("Server error", "error", err)
+				return fmt.Errorf("server error: %v", err)
+			}
+		}
+
+		if errors.Is(err, server.ErrServerClosed) {
+			return nil
+		}
+
+		ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
+		defer cancel()
+
+		slog.Info("Shutting down...")
+
+		if err := srv.Shutdown(ctx); err != nil {
+			slog.Error("Failed to shutdown server", "error", err)
+			return fmt.Errorf("failed to shutdown server: %v", err)
+		}
+
+		return nil
+	},
+}

internal/cmd/server_other.go 🔗

@@ -0,0 +1,13 @@
+//go:build !windows
+// +build !windows
+
+package cmd
+
+import (
+	"os"
+	"syscall"
+)
+
+func addSignals(sigs []os.Signal) []os.Signal {
+	return append(sigs, syscall.SIGTERM)
+}

internal/cmd/server_windows.go 🔗

@@ -0,0 +1,10 @@
+//go:build windows
+// +build windows
+
+package cmd
+
+import "os"
+
+func addSignals(sigs []os.Signal) []os.Signal {
+	return sigs
+}

internal/config/config.go 🔗

@@ -213,8 +213,7 @@ func (c Completions) Limits() (depth, items int) {
 }
 
 type Permissions struct {
-	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
-	SkipRequests bool     `json:"-"`                                                                                                                              // Automatically accept all permissions (YOLO mode)
+	AllowedTools []string `json:"allowed_tools,omitempty" jsonschema:"description=List of tools that don't require permission prompts,example=bash,example=view"`
 }
 
 type TrailerStyle string

internal/config/load.go 🔗

@@ -22,7 +22,6 @@ import (
 	"github.com/charmbracelet/crush/internal/env"
 	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/home"
-	"github.com/charmbracelet/crush/internal/log"
 	powernapConfig "github.com/charmbracelet/x/powernap/pkg/config"
 	"github.com/qjebbs/go-jsons"
 )
@@ -52,12 +51,6 @@ func Load(workingDir, dataDir string, debug bool) (*ConfigStore, error) {
 		cfg.Options.Debug = true
 	}
 
-	// Setup logs
-	log.Setup(
-		filepath.Join(cfg.Options.DataDirectory, "logs", fmt.Sprintf("%s.log", appName)),
-		cfg.Options.Debug,
-	)
-
 	// Load workspace config last so it has highest priority.
 	if wsData, err := os.ReadFile(store.workspacePath); err == nil && len(wsData) > 0 {
 		merged, mergeErr := loadFromBytes(append([][]byte{mustMarshalConfig(cfg)}, wsData))
@@ -749,6 +742,25 @@ func GlobalConfig() string {
 	return filepath.Join(home.Config(), appName, fmt.Sprintf("%s.json", appName))
 }
 
+// GlobalCacheDir returns the path to the global cache directory for the
+// application.
+func GlobalCacheDir() string {
+	if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
+		return crushCache
+	}
+	if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
+		return filepath.Join(xdgCacheHome, appName)
+	}
+	if runtime.GOOS == "windows" {
+		localAppData := cmp.Or(
+			os.Getenv("LOCALAPPDATA"),
+			filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
+		)
+		return filepath.Join(localAppData, appName, "cache")
+	}
+	return filepath.Join(home.Dir(), ".cache", appName)
+}
+
 // GlobalConfigData returns the path to the main data directory for the application.
 // this config is used when the app overrides configurations instead of updating the global config.
 func GlobalConfigData() string {
@@ -773,6 +785,15 @@ func GlobalConfigData() string {
 	return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
 }
 
+// GlobalWorkspaceDir returns the path to the global server workspace
+// directory. This directory acts as a meta-workspace for the server
+// process, giving it a real workingDir so that config loading, scoped
+// writes, and provider resolution behave identically to project
+// workspaces.
+func GlobalWorkspaceDir() string {
+	return filepath.Dir(GlobalConfigData())
+}
+
 func assignIfNil[T any](ptr **T, val T) {
 	if *ptr == nil {
 		*ptr = &val

internal/config/resolve.go 🔗

@@ -14,6 +14,20 @@ type VariableResolver interface {
 	ResolveValue(value string) (string, error)
 }
 
+// identityResolver is a no-op resolver that returns values unchanged.
+// Used in client mode where variable resolution is handled server-side.
+type identityResolver struct{}
+
+func (identityResolver) ResolveValue(value string) (string, error) {
+	return value, nil
+}
+
+// IdentityResolver returns a VariableResolver that passes values through
+// unchanged.
+func IdentityResolver() VariableResolver {
+	return identityResolver{}
+}
+
 type Shell interface {
 	Exec(ctx context.Context, command string) (stdout, stderr string, err error)
 }

internal/config/scope.go 🔗

@@ -1,5 +1,7 @@
 package config
 
+import "fmt"
+
 // Scope determines which config file is targeted for read/write operations.
 type Scope int
 
@@ -9,3 +11,19 @@ const (
 	// ScopeWorkspace targets the workspace config (.crush/crush.json).
 	ScopeWorkspace
 )
+
+// String returns a human-readable label for the scope.
+func (s Scope) String() string {
+	switch s {
+	case ScopeGlobal:
+		return "global"
+	case ScopeWorkspace:
+		return "workspace"
+	default:
+		return fmt.Sprintf("Scope(%d)", int(s))
+	}
+}
+
+// ErrNoWorkspaceConfig is returned when a workspace-scoped write is
+// attempted on a ConfigStore that has no workspace config path.
+var ErrNoWorkspaceConfig = fmt.Errorf("no workspace config path configured")

internal/config/store.go 🔗

@@ -18,6 +18,13 @@ import (
 	"github.com/tidwall/sjson"
 )
 
+// RuntimeOverrides holds per-session settings that are never persisted to
+// disk. They are applied on top of the loaded Config and survive only for
+// the lifetime of the process (or workspace).
+type RuntimeOverrides struct {
+	SkipPermissionRequests bool
+}
+
 // ConfigStore is the single entry point for all config access. It owns the
 // pure-data Config, runtime state (working directory, resolver, known
 // providers), and persistence to both global and workspace config files.
@@ -28,6 +35,7 @@ type ConfigStore struct {
 	globalDataPath string // ~/.local/share/crush/crush.json
 	workspacePath  string // .crush/crush.json
 	knownProviders []catwalk.Provider
+	overrides      RuntimeOverrides
 }
 
 // Config returns the pure-data config struct (read-only after load).
@@ -63,20 +71,32 @@ func (s *ConfigStore) SetupAgents() {
 	s.config.SetupAgents()
 }
 
+// Overrides returns the runtime overrides for this store.
+func (s *ConfigStore) Overrides() *RuntimeOverrides {
+	return &s.overrides
+}
+
 // configPath returns the file path for the given scope.
-func (s *ConfigStore) configPath(scope Scope) string {
+func (s *ConfigStore) configPath(scope Scope) (string, error) {
 	switch scope {
 	case ScopeWorkspace:
-		return s.workspacePath
+		if s.workspacePath == "" {
+			return "", ErrNoWorkspaceConfig
+		}
+		return s.workspacePath, nil
 	default:
-		return s.globalDataPath
+		return s.globalDataPath, nil
 	}
 }
 
 // HasConfigField checks whether a key exists in the config file for the given
 // scope.
 func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
-	data, err := os.ReadFile(s.configPath(scope))
+	path, err := s.configPath(scope)
+	if err != nil {
+		return false
+	}
+	data, err := os.ReadFile(path)
 	if err != nil {
 		return false
 	}
@@ -85,7 +105,10 @@ func (s *ConfigStore) HasConfigField(scope Scope, key string) bool {
 
 // SetConfigField sets a key/value pair in the config file for the given scope.
 func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
-	path := s.configPath(scope)
+	path, err := s.configPath(scope)
+	if err != nil {
+		return fmt.Errorf("%s: %w", key, err)
+	}
 	data, err := os.ReadFile(path)
 	if err != nil {
 		if os.IsNotExist(err) {
@@ -110,7 +133,10 @@ func (s *ConfigStore) SetConfigField(scope Scope, key string, value any) error {
 
 // RemoveConfigField removes a key from the config file for the given scope.
 func (s *ConfigStore) RemoveConfigField(scope Scope, key string) error {
-	path := s.configPath(scope)
+	path, err := s.configPath(scope)
+	if err != nil {
+		return fmt.Errorf("%s: %w", key, err)
+	}
 	data, err := os.ReadFile(path)
 	if err != nil {
 		return fmt.Errorf("failed to read config file: %w", err)

internal/config/store_test.go 🔗

@@ -0,0 +1,152 @@
+package config
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestConfigStore_ConfigPath_GlobalAlwaysWorks(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{
+		globalDataPath: "/some/global/crush.json",
+	}
+
+	path, err := store.configPath(ScopeGlobal)
+	require.NoError(t, err)
+	require.Equal(t, "/some/global/crush.json", path)
+}
+
+func TestConfigStore_ConfigPath_WorkspaceReturnsPath(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{
+		workspacePath: "/some/workspace/.crush/crush.json",
+	}
+
+	path, err := store.configPath(ScopeWorkspace)
+	require.NoError(t, err)
+	require.Equal(t, "/some/workspace/.crush/crush.json", path)
+}
+
+func TestConfigStore_ConfigPath_WorkspaceErrorsWhenEmpty(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{
+		globalDataPath: "/some/global/crush.json",
+		workspacePath:  "",
+	}
+
+	_, err := store.configPath(ScopeWorkspace)
+	require.Error(t, err)
+	require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
+}
+
+func TestConfigStore_SetConfigField_WorkspaceScopeGuard(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{
+		config:         &Config{},
+		globalDataPath: filepath.Join(t.TempDir(), "global.json"),
+		workspacePath:  "",
+	}
+
+	err := store.SetConfigField(ScopeWorkspace, "foo", "bar")
+	require.Error(t, err)
+	require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
+}
+
+func TestConfigStore_SetConfigField_GlobalScopeAlwaysWorks(t *testing.T) {
+	t.Parallel()
+
+	dir := t.TempDir()
+	globalPath := filepath.Join(dir, "crush.json")
+	store := &ConfigStore{
+		config:         &Config{},
+		globalDataPath: globalPath,
+	}
+
+	err := store.SetConfigField(ScopeGlobal, "foo", "bar")
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(globalPath)
+	require.NoError(t, err)
+	require.Contains(t, string(data), `"foo"`)
+}
+
+func TestConfigStore_RemoveConfigField_WorkspaceScopeGuard(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{
+		config:         &Config{},
+		globalDataPath: filepath.Join(t.TempDir(), "global.json"),
+		workspacePath:  "",
+	}
+
+	err := store.RemoveConfigField(ScopeWorkspace, "foo")
+	require.Error(t, err)
+	require.True(t, errors.Is(err, ErrNoWorkspaceConfig))
+}
+
+func TestConfigStore_HasConfigField_WorkspaceScopeGuard(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{
+		config:         &Config{},
+		globalDataPath: filepath.Join(t.TempDir(), "global.json"),
+		workspacePath:  "",
+	}
+
+	has := store.HasConfigField(ScopeWorkspace, "foo")
+	require.False(t, has)
+}
+
+func TestConfigStore_RuntimeOverrides_Independent(t *testing.T) {
+	t.Parallel()
+
+	store1 := &ConfigStore{config: &Config{}}
+	store2 := &ConfigStore{config: &Config{}}
+
+	require.False(t, store1.Overrides().SkipPermissionRequests)
+	require.False(t, store2.Overrides().SkipPermissionRequests)
+
+	store1.Overrides().SkipPermissionRequests = true
+
+	require.True(t, store1.Overrides().SkipPermissionRequests)
+	require.False(t, store2.Overrides().SkipPermissionRequests)
+}
+
+func TestConfigStore_RuntimeOverrides_MutableViaPointer(t *testing.T) {
+	t.Parallel()
+
+	store := &ConfigStore{config: &Config{}}
+	overrides := store.Overrides()
+
+	require.False(t, overrides.SkipPermissionRequests)
+
+	overrides.SkipPermissionRequests = true
+	require.True(t, store.Overrides().SkipPermissionRequests)
+}
+
+func TestGlobalWorkspaceDir(t *testing.T) {
+	dir := t.TempDir()
+	t.Setenv("CRUSH_GLOBAL_DATA", dir)
+
+	wsDir := GlobalWorkspaceDir()
+	globalData := GlobalConfigData()
+
+	require.Equal(t, filepath.Dir(globalData), wsDir)
+	require.Equal(t, dir, wsDir)
+}
+
+func TestScope_String(t *testing.T) {
+	t.Parallel()
+
+	require.Equal(t, "global", ScopeGlobal.String())
+	require.Equal(t, "workspace", ScopeWorkspace.String())
+	require.Contains(t, Scope(99).String(), "Scope(99)")
+}

internal/log/log.go 🔗

@@ -2,6 +2,7 @@ package log
 
 import (
 	"fmt"
+	"io"
 	"log/slog"
 	"os"
 	"runtime/debug"
@@ -10,6 +11,7 @@ import (
 	"time"
 
 	"github.com/charmbracelet/crush/internal/event"
+	"github.com/charmbracelet/x/term"
 	"gopkg.in/natefinch/lumberjack.v2"
 )
 
@@ -18,7 +20,7 @@ var (
 	initialized atomic.Bool
 )
 
-func Setup(logFile string, debug bool) {
+func Setup(logFile string, debug bool, ws ...io.Writer) {
 	initOnce.Do(func() {
 		logRotator := &lumberjack.Logger{
 			Filename:   logFile,
@@ -33,12 +35,26 @@ func Setup(logFile string, debug bool) {
 			level = slog.LevelDebug
 		}
 
-		logger := slog.NewJSONHandler(logRotator, &slog.HandlerOptions{
+		opts := &slog.HandlerOptions{
 			Level:     level,
 			AddSource: true,
-		})
+		}
+
+		var handlers []slog.Handler
+		handlers = append(handlers, slog.NewJSONHandler(logRotator, opts))
+
+		for _, w := range ws {
+			if w == nil {
+				continue
+			}
+			if f, ok := w.(term.File); ok && term.IsTerminal(f.Fd()) {
+				handlers = append(handlers, slog.NewTextHandler(w, opts))
+			} else {
+				handlers = append(handlers, slog.NewJSONHandler(w, opts))
+			}
+		}
 
-		slog.SetDefault(slog.New(logger))
+		slog.SetDefault(slog.New(slog.NewMultiHandler(handlers...)))
 		initialized.Store(true)
 	})
 }

internal/proto/agent.go 🔗

@@ -0,0 +1,75 @@
+package proto
+
+import (
+	"encoding/json"
+	"errors"
+)
+
+// AgentEventType represents the type of agent event.
+type AgentEventType string
+
+const (
+	AgentEventTypeError     AgentEventType = "error"
+	AgentEventTypeResponse  AgentEventType = "response"
+	AgentEventTypeSummarize AgentEventType = "summarize"
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (t AgentEventType) MarshalText() ([]byte, error) {
+	return []byte(t), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (t *AgentEventType) UnmarshalText(text []byte) error {
+	*t = AgentEventType(text)
+	return nil
+}
+
+// AgentEvent represents an event emitted by the agent.
+type AgentEvent struct {
+	Type    AgentEventType `json:"type"`
+	Message Message        `json:"message"`
+	Error   error          `json:"error,omitempty"`
+
+	// When summarizing.
+	SessionID    string `json:"session_id,omitempty"`
+	SessionTitle string `json:"session_title,omitempty"`
+	Progress     string `json:"progress,omitempty"`
+	Done         bool   `json:"done,omitempty"`
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (e AgentEvent) MarshalJSON() ([]byte, error) {
+	type Alias AgentEvent
+	return json.Marshal(&struct {
+		Error string `json:"error,omitempty"`
+		Alias
+	}{
+		Error: func() string {
+			if e.Error != nil {
+				return e.Error.Error()
+			}
+			return ""
+		}(),
+		Alias: (Alias)(e),
+	})
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (e *AgentEvent) UnmarshalJSON(data []byte) error {
+	type Alias AgentEvent
+	aux := &struct {
+		Error string `json:"error,omitempty"`
+		Alias
+	}{
+		Alias: (Alias)(*e),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	*e = AgentEvent(aux.Alias)
+	if aux.Error != "" {
+		e.Error = errors.New(aux.Error)
+	}
+	return nil
+}

internal/proto/history.go 🔗

@@ -0,0 +1,12 @@
+package proto
+
+// File represents a file tracked in session history.
+type File struct {
+	ID        string `json:"id"`
+	SessionID string `json:"session_id"`
+	Path      string `json:"path"`
+	Content   string `json:"content"`
+	Version   int64  `json:"version"`
+	CreatedAt int64  `json:"created_at"`
+	UpdatedAt int64  `json:"updated_at"`
+}

internal/proto/mcp.go 🔗

@@ -0,0 +1,172 @@
+package proto
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+)
+
+// MCPState represents the current state of an MCP client.
+type MCPState int
+
+const (
+	MCPStateDisabled MCPState = iota
+	MCPStateStarting
+	MCPStateConnected
+	MCPStateError
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (s MCPState) MarshalText() ([]byte, error) {
+	return []byte(s.String()), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (s *MCPState) UnmarshalText(data []byte) error {
+	switch string(data) {
+	case "disabled":
+		*s = MCPStateDisabled
+	case "starting":
+		*s = MCPStateStarting
+	case "connected":
+		*s = MCPStateConnected
+	case "error":
+		*s = MCPStateError
+	default:
+		return fmt.Errorf("unknown mcp state: %s", data)
+	}
+	return nil
+}
+
+// String returns the string representation of the MCPState.
+func (s MCPState) String() string {
+	switch s {
+	case MCPStateDisabled:
+		return "disabled"
+	case MCPStateStarting:
+		return "starting"
+	case MCPStateConnected:
+		return "connected"
+	case MCPStateError:
+		return "error"
+	default:
+		return "unknown"
+	}
+}
+
+// MCPEventType represents the type of MCP event.
+type MCPEventType string
+
+const (
+	MCPEventStateChanged         MCPEventType = "state_changed"
+	MCPEventToolsListChanged     MCPEventType = "tools_list_changed"
+	MCPEventPromptsListChanged   MCPEventType = "prompts_list_changed"
+	MCPEventResourcesListChanged MCPEventType = "resources_list_changed"
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (t MCPEventType) MarshalText() ([]byte, error) {
+	return []byte(t), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (t *MCPEventType) UnmarshalText(data []byte) error {
+	*t = MCPEventType(data)
+	return nil
+}
+
+// MCPEvent represents an event in the MCP system.
+type MCPEvent struct {
+	Type          MCPEventType `json:"type"`
+	Name          string       `json:"name"`
+	State         MCPState     `json:"state"`
+	Error         error        `json:"error,omitempty"`
+	ToolCount     int          `json:"tool_count,omitempty"`
+	PromptCount   int          `json:"prompt_count,omitempty"`
+	ResourceCount int          `json:"resource_count,omitempty"`
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (e MCPEvent) MarshalJSON() ([]byte, error) {
+	type Alias MCPEvent
+	return json.Marshal(&struct {
+		Error string `json:"error,omitempty"`
+		Alias
+	}{
+		Error: func() string {
+			if e.Error != nil {
+				return e.Error.Error()
+			}
+			return ""
+		}(),
+		Alias: (Alias)(e),
+	})
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (e *MCPEvent) UnmarshalJSON(data []byte) error {
+	type Alias MCPEvent
+	aux := &struct {
+		Error string `json:"error,omitempty"`
+		Alias
+	}{
+		Alias: (Alias)(*e),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	*e = MCPEvent(aux.Alias)
+	if aux.Error != "" {
+		e.Error = errors.New(aux.Error)
+	}
+	return nil
+}
+
+// MCPClientInfo is the wire-format representation of an MCP client's
+// state, suitable for JSON transport between server and client.
+type MCPClientInfo struct {
+	Name          string    `json:"name"`
+	State         MCPState  `json:"state"`
+	Error         error     `json:"error,omitempty"`
+	ToolCount     int       `json:"tool_count,omitempty"`
+	PromptCount   int       `json:"prompt_count,omitempty"`
+	ResourceCount int       `json:"resource_count,omitempty"`
+	ConnectedAt   time.Time `json:"connected_at"`
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (i MCPClientInfo) MarshalJSON() ([]byte, error) {
+	type Alias MCPClientInfo
+	return json.Marshal(&struct {
+		Error string `json:"error,omitempty"`
+		Alias
+	}{
+		Error: func() string {
+			if i.Error != nil {
+				return i.Error.Error()
+			}
+			return ""
+		}(),
+		Alias: (Alias)(i),
+	})
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (i *MCPClientInfo) UnmarshalJSON(data []byte) error {
+	type Alias MCPClientInfo
+	aux := &struct {
+		Error string `json:"error,omitempty"`
+		Alias
+	}{
+		Alias: (Alias)(*i),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	*i = MCPClientInfo(aux.Alias)
+	if aux.Error != "" {
+		i.Error = errors.New(aux.Error)
+	}
+	return nil
+}

internal/proto/message.go 🔗

@@ -0,0 +1,653 @@
+package proto
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"slices"
+	"time"
+
+	"charm.land/catwalk/pkg/catwalk"
+)
+
+// CreateMessageParams represents parameters for creating a message.
+type CreateMessageParams struct {
+	Role     MessageRole   `json:"role"`
+	Parts    []ContentPart `json:"parts"`
+	Model    string        `json:"model"`
+	Provider string        `json:"provider,omitempty"`
+}
+
+// Message represents a message in the proto layer.
+type Message struct {
+	ID        string        `json:"id"`
+	Role      MessageRole   `json:"role"`
+	SessionID string        `json:"session_id"`
+	Parts     []ContentPart `json:"parts"`
+	Model     string        `json:"model"`
+	Provider  string        `json:"provider"`
+	CreatedAt int64         `json:"created_at"`
+	UpdatedAt int64         `json:"updated_at"`
+}
+
+// MessageRole represents the role of a message sender.
+type MessageRole string
+
+const (
+	Assistant MessageRole = "assistant"
+	User      MessageRole = "user"
+	System    MessageRole = "system"
+	Tool      MessageRole = "tool"
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (r MessageRole) MarshalText() ([]byte, error) {
+	return []byte(r), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (r *MessageRole) UnmarshalText(data []byte) error {
+	*r = MessageRole(data)
+	return nil
+}
+
+// FinishReason represents why a message generation finished.
+type FinishReason string
+
+const (
+	FinishReasonEndTurn          FinishReason = "end_turn"
+	FinishReasonMaxTokens        FinishReason = "max_tokens"
+	FinishReasonToolUse          FinishReason = "tool_use"
+	FinishReasonCanceled         FinishReason = "canceled"
+	FinishReasonError            FinishReason = "error"
+	FinishReasonPermissionDenied FinishReason = "permission_denied"
+	FinishReasonUnknown          FinishReason = "unknown"
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (fr FinishReason) MarshalText() ([]byte, error) {
+	return []byte(fr), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (fr *FinishReason) UnmarshalText(data []byte) error {
+	*fr = FinishReason(data)
+	return nil
+}
+
+// ContentPart is a part of a message's content.
+type ContentPart interface {
+	isPart()
+}
+
+// ReasoningContent represents the reasoning/thinking part of a message.
+type ReasoningContent struct {
+	Thinking   string `json:"thinking"`
+	Signature  string `json:"signature"`
+	StartedAt  int64  `json:"started_at,omitempty"`
+	FinishedAt int64  `json:"finished_at,omitempty"`
+}
+
+// String returns the thinking content as a string.
+func (tc ReasoningContent) String() string {
+	return tc.Thinking
+}
+
+func (ReasoningContent) isPart() {}
+
+// TextContent represents a text part of a message.
+type TextContent struct {
+	Text string `json:"text"`
+}
+
+// String returns the text content as a string.
+func (tc TextContent) String() string {
+	return tc.Text
+}
+
+func (TextContent) isPart() {}
+
+// ImageURLContent represents an image URL part of a message.
+type ImageURLContent struct {
+	URL    string `json:"url"`
+	Detail string `json:"detail,omitempty"`
+}
+
+// String returns the image URL as a string.
+func (iuc ImageURLContent) String() string {
+	return iuc.URL
+}
+
+func (ImageURLContent) isPart() {}
+
+// BinaryContent represents binary data in a message.
+type BinaryContent struct {
+	Path     string
+	MIMEType string
+	Data     []byte
+}
+
+// String returns a base64-encoded string of the binary data.
+func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
+	base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
+	if p == catwalk.InferenceProviderOpenAI {
+		return "data:" + bc.MIMEType + ";base64," + base64Encoded
+	}
+	return base64Encoded
+}
+
+func (BinaryContent) isPart() {}
+
+// ToolCall represents a tool call in a message.
+type ToolCall struct {
+	ID       string `json:"id"`
+	Name     string `json:"name"`
+	Input    string `json:"input"`
+	Type     string `json:"type,omitempty"`
+	Finished bool   `json:"finished,omitempty"`
+}
+
+func (ToolCall) isPart() {}
+
+// ToolResult represents the result of a tool call.
+type ToolResult struct {
+	ToolCallID string `json:"tool_call_id"`
+	Name       string `json:"name"`
+	Content    string `json:"content"`
+	Metadata   string `json:"metadata"`
+	IsError    bool   `json:"is_error"`
+}
+
+func (ToolResult) isPart() {}
+
+// Finish represents the end of a message generation.
+type Finish struct {
+	Reason  FinishReason `json:"reason"`
+	Time    int64        `json:"time"`
+	Message string       `json:"message,omitempty"`
+	Details string       `json:"details,omitempty"`
+}
+
+func (Finish) isPart() {}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (m Message) MarshalJSON() ([]byte, error) {
+	parts, err := MarshalParts(m.Parts)
+	if err != nil {
+		return nil, err
+	}
+
+	type Alias Message
+	return json.Marshal(&struct {
+		Parts json.RawMessage `json:"parts"`
+		*Alias
+	}{
+		Parts: json.RawMessage(parts),
+		Alias: (*Alias)(&m),
+	})
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (m *Message) UnmarshalJSON(data []byte) error {
+	type Alias Message
+	aux := &struct {
+		Parts json.RawMessage `json:"parts"`
+		*Alias
+	}{
+		Alias: (*Alias)(m),
+	}
+
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+
+	parts, err := UnmarshalParts([]byte(aux.Parts))
+	if err != nil {
+		return err
+	}
+
+	m.Parts = parts
+	return nil
+}
+
+// Content returns the first text content part.
+func (m *Message) Content() TextContent {
+	for _, part := range m.Parts {
+		if c, ok := part.(TextContent); ok {
+			return c
+		}
+	}
+	return TextContent{}
+}
+
+// ReasoningContent returns the first reasoning content part.
+func (m *Message) ReasoningContent() ReasoningContent {
+	for _, part := range m.Parts {
+		if c, ok := part.(ReasoningContent); ok {
+			return c
+		}
+	}
+	return ReasoningContent{}
+}
+
+// ImageURLContent returns all image URL content parts.
+func (m *Message) ImageURLContent() []ImageURLContent {
+	imageURLContents := make([]ImageURLContent, 0)
+	for _, part := range m.Parts {
+		if c, ok := part.(ImageURLContent); ok {
+			imageURLContents = append(imageURLContents, c)
+		}
+	}
+	return imageURLContents
+}
+
+// BinaryContent returns all binary content parts.
+func (m *Message) BinaryContent() []BinaryContent {
+	binaryContents := make([]BinaryContent, 0)
+	for _, part := range m.Parts {
+		if c, ok := part.(BinaryContent); ok {
+			binaryContents = append(binaryContents, c)
+		}
+	}
+	return binaryContents
+}
+
+// ToolCalls returns all tool call parts.
+func (m *Message) ToolCalls() []ToolCall {
+	toolCalls := make([]ToolCall, 0)
+	for _, part := range m.Parts {
+		if c, ok := part.(ToolCall); ok {
+			toolCalls = append(toolCalls, c)
+		}
+	}
+	return toolCalls
+}
+
+// ToolResults returns all tool result parts.
+func (m *Message) ToolResults() []ToolResult {
+	toolResults := make([]ToolResult, 0)
+	for _, part := range m.Parts {
+		if c, ok := part.(ToolResult); ok {
+			toolResults = append(toolResults, c)
+		}
+	}
+	return toolResults
+}
+
+// IsFinished returns true if the message has a finish part.
+func (m *Message) IsFinished() bool {
+	for _, part := range m.Parts {
+		if _, ok := part.(Finish); ok {
+			return true
+		}
+	}
+	return false
+}
+
+// FinishPart returns the finish part if present.
+func (m *Message) FinishPart() *Finish {
+	for _, part := range m.Parts {
+		if c, ok := part.(Finish); ok {
+			return &c
+		}
+	}
+	return nil
+}
+
+// FinishReason returns the finish reason if present.
+func (m *Message) FinishReason() FinishReason {
+	for _, part := range m.Parts {
+		if c, ok := part.(Finish); ok {
+			return c.Reason
+		}
+	}
+	return ""
+}
+
+// IsThinking returns true if the message is currently in a thinking state.
+func (m *Message) IsThinking() bool {
+	return m.ReasoningContent().Thinking != "" && m.Content().Text == "" && !m.IsFinished()
+}
+
+// AppendContent appends text to the text content part.
+func (m *Message) AppendContent(delta string) {
+	found := false
+	for i, part := range m.Parts {
+		if c, ok := part.(TextContent); ok {
+			m.Parts[i] = TextContent{Text: c.Text + delta}
+			found = true
+		}
+	}
+	if !found {
+		m.Parts = append(m.Parts, TextContent{Text: delta})
+	}
+}
+
+// AppendReasoningContent appends text to the reasoning content part.
+func (m *Message) AppendReasoningContent(delta string) {
+	found := false
+	for i, part := range m.Parts {
+		if c, ok := part.(ReasoningContent); ok {
+			m.Parts[i] = ReasoningContent{
+				Thinking:   c.Thinking + delta,
+				Signature:  c.Signature,
+				StartedAt:  c.StartedAt,
+				FinishedAt: c.FinishedAt,
+			}
+			found = true
+		}
+	}
+	if !found {
+		m.Parts = append(m.Parts, ReasoningContent{
+			Thinking:  delta,
+			StartedAt: time.Now().Unix(),
+		})
+	}
+}
+
+// AppendReasoningSignature appends a signature to the reasoning content part.
+func (m *Message) AppendReasoningSignature(signature string) {
+	for i, part := range m.Parts {
+		if c, ok := part.(ReasoningContent); ok {
+			m.Parts[i] = ReasoningContent{
+				Thinking:   c.Thinking,
+				Signature:  c.Signature + signature,
+				StartedAt:  c.StartedAt,
+				FinishedAt: c.FinishedAt,
+			}
+			return
+		}
+	}
+	m.Parts = append(m.Parts, ReasoningContent{Signature: signature})
+}
+
+// FinishThinking marks the reasoning content as finished.
+func (m *Message) FinishThinking() {
+	for i, part := range m.Parts {
+		if c, ok := part.(ReasoningContent); ok {
+			if c.FinishedAt == 0 {
+				m.Parts[i] = ReasoningContent{
+					Thinking:   c.Thinking,
+					Signature:  c.Signature,
+					StartedAt:  c.StartedAt,
+					FinishedAt: time.Now().Unix(),
+				}
+			}
+			return
+		}
+	}
+}
+
+// ThinkingDuration returns the duration of the thinking phase.
+func (m *Message) ThinkingDuration() time.Duration {
+	reasoning := m.ReasoningContent()
+	if reasoning.StartedAt == 0 {
+		return 0
+	}
+
+	endTime := reasoning.FinishedAt
+	if endTime == 0 {
+		endTime = time.Now().Unix()
+	}
+
+	return time.Duration(endTime-reasoning.StartedAt) * time.Second
+}
+
+// FinishToolCall marks a tool call as finished.
+func (m *Message) FinishToolCall(toolCallID string) {
+	for i, part := range m.Parts {
+		if c, ok := part.(ToolCall); ok {
+			if c.ID == toolCallID {
+				m.Parts[i] = ToolCall{
+					ID:       c.ID,
+					Name:     c.Name,
+					Input:    c.Input,
+					Type:     c.Type,
+					Finished: true,
+				}
+				return
+			}
+		}
+	}
+}
+
+// AppendToolCallInput appends input to a tool call.
+func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) {
+	for i, part := range m.Parts {
+		if c, ok := part.(ToolCall); ok {
+			if c.ID == toolCallID {
+				m.Parts[i] = ToolCall{
+					ID:       c.ID,
+					Name:     c.Name,
+					Input:    c.Input + inputDelta,
+					Type:     c.Type,
+					Finished: c.Finished,
+				}
+				return
+			}
+		}
+	}
+}
+
+// AddToolCall adds or updates a tool call.
+func (m *Message) AddToolCall(tc ToolCall) {
+	for i, part := range m.Parts {
+		if c, ok := part.(ToolCall); ok {
+			if c.ID == tc.ID {
+				m.Parts[i] = tc
+				return
+			}
+		}
+	}
+	m.Parts = append(m.Parts, tc)
+}
+
+// SetToolCalls replaces all tool call parts.
+func (m *Message) SetToolCalls(tc []ToolCall) {
+	parts := make([]ContentPart, 0)
+	for _, part := range m.Parts {
+		if _, ok := part.(ToolCall); ok {
+			continue
+		}
+		parts = append(parts, part)
+	}
+	m.Parts = parts
+	for _, toolCall := range tc {
+		m.Parts = append(m.Parts, toolCall)
+	}
+}
+
+// AddToolResult adds a tool result.
+func (m *Message) AddToolResult(tr ToolResult) {
+	m.Parts = append(m.Parts, tr)
+}
+
+// SetToolResults adds multiple tool results.
+func (m *Message) SetToolResults(tr []ToolResult) {
+	for _, toolResult := range tr {
+		m.Parts = append(m.Parts, toolResult)
+	}
+}
+
+// AddFinish adds a finish part to the message.
+func (m *Message) AddFinish(reason FinishReason, message, details string) {
+	for i, part := range m.Parts {
+		if _, ok := part.(Finish); ok {
+			m.Parts = slices.Delete(m.Parts, i, i+1)
+			break
+		}
+	}
+	m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix(), Message: message, Details: details})
+}
+
+// AddImageURL adds an image URL part to the message.
+func (m *Message) AddImageURL(url, detail string) {
+	m.Parts = append(m.Parts, ImageURLContent{URL: url, Detail: detail})
+}
+
+// AddBinary adds a binary content part to the message.
+func (m *Message) AddBinary(mimeType string, data []byte) {
+	m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
+}
+
+type partType string
+
+const (
+	reasoningType  partType = "reasoning"
+	textType       partType = "text"
+	imageURLType   partType = "image_url"
+	binaryType     partType = "binary"
+	toolCallType   partType = "tool_call"
+	toolResultType partType = "tool_result"
+	finishType     partType = "finish"
+)
+
+type partWrapper struct {
+	Type partType    `json:"type"`
+	Data ContentPart `json:"data"`
+}
+
+// MarshalParts marshals content parts to JSON.
+func MarshalParts(parts []ContentPart) ([]byte, error) {
+	wrappedParts := make([]partWrapper, len(parts))
+
+	for i, part := range parts {
+		var typ partType
+
+		switch part.(type) {
+		case ReasoningContent:
+			typ = reasoningType
+		case TextContent:
+			typ = textType
+		case ImageURLContent:
+			typ = imageURLType
+		case BinaryContent:
+			typ = binaryType
+		case ToolCall:
+			typ = toolCallType
+		case ToolResult:
+			typ = toolResultType
+		case Finish:
+			typ = finishType
+		default:
+			return nil, fmt.Errorf("unknown part type: %T", part)
+		}
+
+		wrappedParts[i] = partWrapper{
+			Type: typ,
+			Data: part,
+		}
+	}
+	return json.Marshal(wrappedParts)
+}
+
+// UnmarshalParts unmarshals content parts from JSON.
+func UnmarshalParts(data []byte) ([]ContentPart, error) {
+	temp := []json.RawMessage{}
+
+	if err := json.Unmarshal(data, &temp); err != nil {
+		return nil, err
+	}
+
+	parts := make([]ContentPart, 0)
+
+	for _, rawPart := range temp {
+		var wrapper struct {
+			Type partType        `json:"type"`
+			Data json.RawMessage `json:"data"`
+		}
+
+		if err := json.Unmarshal(rawPart, &wrapper); err != nil {
+			return nil, err
+		}
+
+		switch wrapper.Type {
+		case reasoningType:
+			part := ReasoningContent{}
+			if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+				return nil, err
+			}
+			parts = append(parts, part)
+		case textType:
+			part := TextContent{}
+			if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+				return nil, err
+			}
+			parts = append(parts, part)
+		case imageURLType:
+			part := ImageURLContent{}
+			if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+				return nil, err
+			}
+			parts = append(parts, part)
+		case binaryType:
+			part := BinaryContent{}
+			if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+				return nil, err
+			}
+			parts = append(parts, part)
+		case toolCallType:
+			part := ToolCall{}
+			if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+				return nil, err
+			}
+			parts = append(parts, part)
+		case toolResultType:
+			part := ToolResult{}
+			if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+				return nil, err
+			}
+			parts = append(parts, part)
+		case finishType:
+			part := Finish{}
+			if err := json.Unmarshal(wrapper.Data, &part); err != nil {
+				return nil, err
+			}
+			parts = append(parts, part)
+		default:
+			return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
+		}
+	}
+
+	return parts, nil
+}
+
+// Attachment represents a file attachment.
+type Attachment struct {
+	FilePath string `json:"file_path"`
+	FileName string `json:"file_name"`
+	MimeType string `json:"mime_type"`
+	Content  []byte `json:"content"`
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (a Attachment) MarshalJSON() ([]byte, error) {
+	type Alias Attachment
+	return json.Marshal(&struct {
+		Content string `json:"content"`
+		*Alias
+	}{
+		Content: base64.StdEncoding.EncodeToString(a.Content),
+		Alias:   (*Alias)(&a),
+	})
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (a *Attachment) UnmarshalJSON(data []byte) error {
+	type Alias Attachment
+	aux := &struct {
+		Content string `json:"content"`
+		*Alias
+	}{
+		Alias: (*Alias)(a),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	content, err := base64.StdEncoding.DecodeString(aux.Content)
+	if err != nil {
+		return err
+	}
+	a.Content = content
+	return nil
+}

internal/proto/permission.go 🔗

@@ -0,0 +1,141 @@
+package proto
+
+import (
+	"encoding/json"
+)
+
+// CreatePermissionRequest represents a request to create a permission.
+type CreatePermissionRequest struct {
+	SessionID   string `json:"session_id"`
+	ToolCallID  string `json:"tool_call_id"`
+	ToolName    string `json:"tool_name"`
+	Description string `json:"description"`
+	Action      string `json:"action"`
+	Params      any    `json:"params"`
+	Path        string `json:"path"`
+}
+
+// PermissionNotification represents a notification about a permission change.
+type PermissionNotification struct {
+	ToolCallID string `json:"tool_call_id"`
+	Granted    bool   `json:"granted"`
+	Denied     bool   `json:"denied"`
+}
+
+// PermissionRequest represents a pending permission request.
+type PermissionRequest struct {
+	ID          string `json:"id"`
+	SessionID   string `json:"session_id"`
+	ToolCallID  string `json:"tool_call_id"`
+	ToolName    string `json:"tool_name"`
+	Description string `json:"description"`
+	Action      string `json:"action"`
+	Params      any    `json:"params"`
+	Path        string `json:"path"`
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface. This is needed
+// because the Params field is of type any, so we need to unmarshal it into
+// its appropriate type based on the [PermissionRequest.ToolName].
+func (p *PermissionRequest) UnmarshalJSON(data []byte) error {
+	type Alias PermissionRequest
+	aux := &struct {
+		Params json.RawMessage `json:"params"`
+		*Alias
+	}{
+		Alias: (*Alias)(p),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+
+	params, err := unmarshalToolParams(p.ToolName, aux.Params)
+	if err != nil {
+		return err
+	}
+	p.Params = params
+	return nil
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface. This is needed
+// because the Params field is of type any, so we need to unmarshal it into
+// its appropriate type based on the [CreatePermissionRequest.ToolName].
+func (p *CreatePermissionRequest) UnmarshalJSON(data []byte) error {
+	type Alias CreatePermissionRequest
+	aux := &struct {
+		Params json.RawMessage `json:"params"`
+		*Alias
+	}{
+		Alias: (*Alias)(p),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+
+	params, err := unmarshalToolParams(p.ToolName, aux.Params)
+	if err != nil {
+		return err
+	}
+	p.Params = params
+	return nil
+}
+
+func unmarshalToolParams(toolName string, raw json.RawMessage) (any, error) {
+	switch toolName {
+	case BashToolName:
+		var params BashPermissionsParams
+		if err := json.Unmarshal(raw, &params); err != nil {
+			return nil, err
+		}
+		return params, nil
+	case DownloadToolName:
+		var params DownloadPermissionsParams
+		if err := json.Unmarshal(raw, &params); err != nil {
+			return nil, err
+		}
+		return params, nil
+	case EditToolName:
+		var params EditPermissionsParams
+		if err := json.Unmarshal(raw, &params); err != nil {
+			return nil, err
+		}
+		return params, nil
+	case WriteToolName:
+		var params WritePermissionsParams
+		if err := json.Unmarshal(raw, &params); err != nil {
+			return nil, err
+		}
+		return params, nil
+	case MultiEditToolName:
+		var params MultiEditPermissionsParams
+		if err := json.Unmarshal(raw, &params); err != nil {
+			return nil, err
+		}
+		return params, nil
+	case FetchToolName:
+		var params FetchPermissionsParams
+		if err := json.Unmarshal(raw, &params); err != nil {
+			return nil, err
+		}
+		return params, nil
+	case ViewToolName:
+		var params ViewPermissionsParams
+		if err := json.Unmarshal(raw, &params); err != nil {
+			return nil, err
+		}
+		return params, nil
+	case LSToolName:
+		var params LSPermissionsParams
+		if err := json.Unmarshal(raw, &params); err != nil {
+			return nil, err
+		}
+		return params, nil
+	default:
+		// For unknown tools, keep the raw JSON as-is.
+		var generic map[string]any
+		if err := json.Unmarshal(raw, &generic); err != nil {
+			return nil, err
+		}
+		return generic, nil
+	}
+}

internal/proto/proto.go 🔗

@@ -0,0 +1,200 @@
+package proto
+
+import (
+	"encoding/json"
+	"errors"
+	"time"
+
+	"charm.land/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/lsp"
+)
+
+// Workspace represents a running app.App workspace with its associated
+// resources and state.
+type Workspace struct {
+	ID      string         `json:"id"`
+	Path    string         `json:"path"`
+	YOLO    bool           `json:"yolo,omitempty"`
+	Debug   bool           `json:"debug,omitempty"`
+	DataDir string         `json:"data_dir,omitempty"`
+	Version string         `json:"version,omitempty"`
+	Config  *config.Config `json:"config,omitempty"`
+	Env     []string       `json:"env,omitempty"`
+}
+
+// Error represents an error response.
+type Error struct {
+	Message string `json:"message"`
+}
+
+// AgentInfo represents information about the agent.
+type AgentInfo struct {
+	IsBusy   bool                 `json:"is_busy"`
+	IsReady  bool                 `json:"is_ready"`
+	Model    catwalk.Model        `json:"model"`
+	ModelCfg config.SelectedModel `json:"model_cfg"`
+}
+
+// IsZero checks if the AgentInfo is zero-valued.
+func (a AgentInfo) IsZero() bool {
+	return !a.IsBusy && !a.IsReady && a.Model.ID == ""
+}
+
+// AgentMessage represents a message sent to the agent.
+type AgentMessage struct {
+	SessionID   string       `json:"session_id"`
+	Prompt      string       `json:"prompt"`
+	Attachments []Attachment `json:"attachments,omitempty"`
+}
+
+// AgentSession represents a session with its busy status.
+type AgentSession struct {
+	Session
+	IsBusy bool `json:"is_busy"`
+}
+
+// IsZero checks if the AgentSession is zero-valued.
+func (a AgentSession) IsZero() bool {
+	return a == AgentSession{}
+}
+
+// PermissionAction represents an action taken on a permission request.
+type PermissionAction string
+
+const (
+	PermissionAllow           PermissionAction = "allow"
+	PermissionAllowForSession PermissionAction = "allow_session"
+	PermissionDeny            PermissionAction = "deny"
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (p PermissionAction) MarshalText() ([]byte, error) {
+	return []byte(p), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (p *PermissionAction) UnmarshalText(text []byte) error {
+	*p = PermissionAction(text)
+	return nil
+}
+
+// PermissionGrant represents a permission grant request.
+type PermissionGrant struct {
+	Permission PermissionRequest `json:"permission"`
+	Action     PermissionAction  `json:"action"`
+}
+
+// PermissionSkipRequest represents a request to skip permission prompts.
+type PermissionSkipRequest struct {
+	Skip bool `json:"skip"`
+}
+
+// LSPEventType represents the type of LSP event.
+type LSPEventType string
+
+const (
+	LSPEventStateChanged       LSPEventType = "state_changed"
+	LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed"
+)
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (e LSPEventType) MarshalText() ([]byte, error) {
+	return []byte(e), nil
+}
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (e *LSPEventType) UnmarshalText(data []byte) error {
+	*e = LSPEventType(data)
+	return nil
+}
+
+// LSPEvent represents an event in the LSP system.
+type LSPEvent struct {
+	Type            LSPEventType    `json:"type"`
+	Name            string          `json:"name"`
+	State           lsp.ServerState `json:"state"`
+	Error           error           `json:"error,omitempty"`
+	DiagnosticCount int             `json:"diagnostic_count,omitempty"`
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (e LSPEvent) MarshalJSON() ([]byte, error) {
+	type Alias LSPEvent
+	return json.Marshal(&struct {
+		Error string `json:"error,omitempty"`
+		Alias
+	}{
+		Error: func() string {
+			if e.Error != nil {
+				return e.Error.Error()
+			}
+			return ""
+		}(),
+		Alias: (Alias)(e),
+	})
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (e *LSPEvent) UnmarshalJSON(data []byte) error {
+	type Alias LSPEvent
+	aux := &struct {
+		Error string `json:"error,omitempty"`
+		Alias
+	}{
+		Alias: (Alias)(*e),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	*e = LSPEvent(aux.Alias)
+	if aux.Error != "" {
+		e.Error = errors.New(aux.Error)
+	}
+	return nil
+}
+
+// LSPClientInfo holds information about an LSP client's state.
+type LSPClientInfo struct {
+	Name            string          `json:"name"`
+	State           lsp.ServerState `json:"state"`
+	Error           error           `json:"error,omitempty"`
+	DiagnosticCount int             `json:"diagnostic_count,omitempty"`
+	ConnectedAt     time.Time       `json:"connected_at"`
+}
+
+// MarshalJSON implements the [json.Marshaler] interface.
+func (i LSPClientInfo) MarshalJSON() ([]byte, error) {
+	type Alias LSPClientInfo
+	return json.Marshal(&struct {
+		Error string `json:"error,omitempty"`
+		Alias
+	}{
+		Error: func() string {
+			if i.Error != nil {
+				return i.Error.Error()
+			}
+			return ""
+		}(),
+		Alias: (Alias)(i),
+	})
+}
+
+// UnmarshalJSON implements the [json.Unmarshaler] interface.
+func (i *LSPClientInfo) UnmarshalJSON(data []byte) error {
+	type Alias LSPClientInfo
+	aux := &struct {
+		Error string `json:"error,omitempty"`
+		Alias
+	}{
+		Alias: (Alias)(*i),
+	}
+	if err := json.Unmarshal(data, &aux); err != nil {
+		return err
+	}
+	*i = LSPClientInfo(aux.Alias)
+	if aux.Error != "" {
+		i.Error = errors.New(aux.Error)
+	}
+	return nil
+}

internal/proto/requests.go 🔗

@@ -0,0 +1,92 @@
+package proto
+
+import "github.com/charmbracelet/crush/internal/config"
+
+// ConfigSetRequest represents a request to set a config field.
+type ConfigSetRequest struct {
+	Scope config.Scope `json:"scope"`
+	Key   string       `json:"key"`
+	Value any          `json:"value"`
+}
+
+// ConfigRemoveRequest represents a request to remove a config field.
+type ConfigRemoveRequest struct {
+	Scope config.Scope `json:"scope"`
+	Key   string       `json:"key"`
+}
+
+// ConfigModelRequest represents a request to update the preferred model.
+type ConfigModelRequest struct {
+	Scope     config.Scope             `json:"scope"`
+	ModelType config.SelectedModelType `json:"model_type"`
+	Model     config.SelectedModel     `json:"model"`
+}
+
+// ConfigCompactRequest represents a request to set compact mode.
+type ConfigCompactRequest struct {
+	Scope   config.Scope `json:"scope"`
+	Enabled bool         `json:"enabled"`
+}
+
+// ConfigProviderKeyRequest represents a request to set a provider API key.
+type ConfigProviderKeyRequest struct {
+	Scope      config.Scope `json:"scope"`
+	ProviderID string       `json:"provider_id"`
+	APIKey     any          `json:"api_key"`
+}
+
+// ConfigRefreshOAuthRequest represents a request to refresh an OAuth token.
+type ConfigRefreshOAuthRequest struct {
+	Scope      config.Scope `json:"scope"`
+	ProviderID string       `json:"provider_id"`
+}
+
+// ImportCopilotResponse represents the response from importing Copilot credentials.
+type ImportCopilotResponse struct {
+	Token   any  `json:"token"`
+	Success bool `json:"success"`
+}
+
+// ProjectNeedsInitResponse represents whether a project needs initialization.
+type ProjectNeedsInitResponse struct {
+	NeedsInit bool `json:"needs_init"`
+}
+
+// ProjectInitPromptResponse represents the project initialization prompt.
+type ProjectInitPromptResponse struct {
+	Prompt string `json:"prompt"`
+}
+
+// LSPStartRequest represents a request to start an LSP for a path.
+type LSPStartRequest struct {
+	Path string `json:"path"`
+}
+
+// FileTrackerReadRequest represents a request to record a file read.
+type FileTrackerReadRequest struct {
+	SessionID string `json:"session_id"`
+	Path      string `json:"path"`
+}
+
+// MCPNameRequest represents a request targeting a named MCP server.
+type MCPNameRequest struct {
+	Name string `json:"name"`
+}
+
+// MCPReadResourceRequest represents a request to read an MCP resource.
+type MCPReadResourceRequest struct {
+	Name string `json:"name"`
+	URI  string `json:"uri"`
+}
+
+// MCPGetPromptRequest represents a request to get an MCP prompt.
+type MCPGetPromptRequest struct {
+	ClientID string            `json:"client_id"`
+	PromptID string            `json:"prompt_id"`
+	Args     map[string]string `json:"args"`
+}
+
+// MCPGetPromptResponse represents the response from getting an MCP prompt.
+type MCPGetPromptResponse struct {
+	Prompt string `json:"prompt"`
+}

internal/proto/server.go 🔗

@@ -0,0 +1,6 @@
+package proto
+
+// ServerControl represents a server control request.
+type ServerControl struct {
+	Command string `json:"command"`
+}

internal/proto/session.go 🔗

@@ -0,0 +1,15 @@
+package proto
+
+// Session represents a session in the proto layer.
+type Session struct {
+	ID               string  `json:"id"`
+	ParentSessionID  string  `json:"parent_session_id"`
+	Title            string  `json:"title"`
+	MessageCount     int64   `json:"message_count"`
+	PromptTokens     int64   `json:"prompt_tokens"`
+	CompletionTokens int64   `json:"completion_tokens"`
+	SummaryMessageID string  `json:"summary_message_id"`
+	Cost             float64 `json:"cost"`
+	CreatedAt        int64   `json:"created_at"`
+	UpdatedAt        int64   `json:"updated_at"`
+}

internal/proto/tools.go 🔗

@@ -0,0 +1,250 @@
+package proto
+
+// ToolResponseType represents the type of tool response.
+type ToolResponseType string
+
+const (
+	ToolResponseTypeText  ToolResponseType = "text"
+	ToolResponseTypeImage ToolResponseType = "image"
+)
+
+// ToolResponse represents a response from a tool.
+type ToolResponse struct {
+	Type     ToolResponseType `json:"type"`
+	Content  string           `json:"content"`
+	Metadata string           `json:"metadata,omitempty"`
+	IsError  bool             `json:"is_error"`
+}
+
+const BashToolName = "bash"
+
+// BashParams represents the parameters for the bash tool.
+type BashParams struct {
+	Command string `json:"command"`
+	Timeout int    `json:"timeout"`
+}
+
+// BashPermissionsParams represents the permission parameters for the bash tool.
+type BashPermissionsParams struct {
+	Command string `json:"command"`
+	Timeout int    `json:"timeout"`
+}
+
+// BashResponseMetadata represents the metadata for a bash tool response.
+type BashResponseMetadata struct {
+	StartTime        int64  `json:"start_time"`
+	EndTime          int64  `json:"end_time"`
+	Output           string `json:"output"`
+	WorkingDirectory string `json:"working_directory"`
+}
+
+// DiagnosticsParams represents the parameters for the diagnostics tool.
+type DiagnosticsParams struct {
+	FilePath string `json:"file_path"`
+}
+
+const DownloadToolName = "download"
+
+// DownloadParams represents the parameters for the download tool.
+type DownloadParams struct {
+	URL      string `json:"url"`
+	FilePath string `json:"file_path"`
+	Timeout  int    `json:"timeout,omitempty"`
+}
+
+// DownloadPermissionsParams represents the permission parameters for the download tool.
+type DownloadPermissionsParams struct {
+	URL      string `json:"url"`
+	FilePath string `json:"file_path"`
+	Timeout  int    `json:"timeout,omitempty"`
+}
+
+const EditToolName = "edit"
+
+// EditParams represents the parameters for the edit tool.
+type EditParams struct {
+	FilePath   string `json:"file_path"`
+	OldString  string `json:"old_string"`
+	NewString  string `json:"new_string"`
+	ReplaceAll bool   `json:"replace_all,omitempty"`
+}
+
+// EditPermissionsParams represents the permission parameters for the edit tool.
+type EditPermissionsParams struct {
+	FilePath   string `json:"file_path"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+// EditResponseMetadata represents the metadata for an edit tool response.
+type EditResponseMetadata struct {
+	Additions  int    `json:"additions"`
+	Removals   int    `json:"removals"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+const FetchToolName = "fetch"
+
+// FetchParams represents the parameters for the fetch tool.
+type FetchParams struct {
+	URL     string `json:"url"`
+	Format  string `json:"format"`
+	Timeout int    `json:"timeout,omitempty"`
+}
+
+// FetchPermissionsParams represents the permission parameters for the fetch tool.
+type FetchPermissionsParams struct {
+	URL     string `json:"url"`
+	Format  string `json:"format"`
+	Timeout int    `json:"timeout,omitempty"`
+}
+
+const GlobToolName = "glob"
+
+// GlobParams represents the parameters for the glob tool.
+type GlobParams struct {
+	Pattern string `json:"pattern"`
+	Path    string `json:"path"`
+}
+
+// GlobResponseMetadata represents the metadata for a glob tool response.
+type GlobResponseMetadata struct {
+	NumberOfFiles int  `json:"number_of_files"`
+	Truncated     bool `json:"truncated"`
+}
+
+const GrepToolName = "grep"
+
+// GrepParams represents the parameters for the grep tool.
+type GrepParams struct {
+	Pattern     string `json:"pattern"`
+	Path        string `json:"path"`
+	Include     string `json:"include"`
+	LiteralText bool   `json:"literal_text"`
+}
+
+// GrepResponseMetadata represents the metadata for a grep tool response.
+type GrepResponseMetadata struct {
+	NumberOfMatches int  `json:"number_of_matches"`
+	Truncated       bool `json:"truncated"`
+}
+
+const LSToolName = "ls"
+
+// LSParams represents the parameters for the ls tool.
+type LSParams struct {
+	Path   string   `json:"path"`
+	Ignore []string `json:"ignore"`
+}
+
+// LSPermissionsParams represents the permission parameters for the ls tool.
+type LSPermissionsParams struct {
+	Path   string   `json:"path"`
+	Ignore []string `json:"ignore"`
+}
+
+// TreeNode represents a node in a directory tree.
+type TreeNode struct {
+	Name     string      `json:"name"`
+	Path     string      `json:"path"`
+	Type     string      `json:"type"`
+	Children []*TreeNode `json:"children,omitempty"`
+}
+
+// LSResponseMetadata represents the metadata for an ls tool response.
+type LSResponseMetadata struct {
+	NumberOfFiles int  `json:"number_of_files"`
+	Truncated     bool `json:"truncated"`
+}
+
+const MultiEditToolName = "multiedit"
+
+// MultiEditOperation represents a single edit operation in a multi-edit.
+type MultiEditOperation struct {
+	OldString  string `json:"old_string"`
+	NewString  string `json:"new_string"`
+	ReplaceAll bool   `json:"replace_all,omitempty"`
+}
+
+// MultiEditParams represents the parameters for the multi-edit tool.
+type MultiEditParams struct {
+	FilePath string               `json:"file_path"`
+	Edits    []MultiEditOperation `json:"edits"`
+}
+
+// MultiEditPermissionsParams represents the permission parameters for the multi-edit tool.
+type MultiEditPermissionsParams struct {
+	FilePath   string `json:"file_path"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+// MultiEditResponseMetadata represents the metadata for a multi-edit tool response.
+type MultiEditResponseMetadata struct {
+	Additions    int    `json:"additions"`
+	Removals     int    `json:"removals"`
+	OldContent   string `json:"old_content,omitempty"`
+	NewContent   string `json:"new_content,omitempty"`
+	EditsApplied int    `json:"edits_applied"`
+}
+
+const SourcegraphToolName = "sourcegraph"
+
+// SourcegraphParams represents the parameters for the sourcegraph tool.
+type SourcegraphParams struct {
+	Query         string `json:"query"`
+	Count         int    `json:"count,omitempty"`
+	ContextWindow int    `json:"context_window,omitempty"`
+	Timeout       int    `json:"timeout,omitempty"`
+}
+
+// SourcegraphResponseMetadata represents the metadata for a sourcegraph tool response.
+type SourcegraphResponseMetadata struct {
+	NumberOfMatches int  `json:"number_of_matches"`
+	Truncated       bool `json:"truncated"`
+}
+
+const ViewToolName = "view"
+
+// ViewParams represents the parameters for the view tool.
+type ViewParams struct {
+	FilePath string `json:"file_path"`
+	Offset   int    `json:"offset"`
+	Limit    int    `json:"limit"`
+}
+
+// ViewPermissionsParams represents the permission parameters for the view tool.
+type ViewPermissionsParams struct {
+	FilePath string `json:"file_path"`
+	Offset   int    `json:"offset"`
+	Limit    int    `json:"limit"`
+}
+
+// ViewResponseMetadata represents the metadata for a view tool response.
+type ViewResponseMetadata struct {
+	FilePath string `json:"file_path"`
+	Content  string `json:"content"`
+}
+
+const WriteToolName = "write"
+
+// WriteParams represents the parameters for the write tool.
+type WriteParams struct {
+	FilePath string `json:"file_path"`
+	Content  string `json:"content"`
+}
+
+// WritePermissionsParams represents the permission parameters for the write tool.
+type WritePermissionsParams struct {
+	FilePath   string `json:"file_path"`
+	OldContent string `json:"old_content,omitempty"`
+	NewContent string `json:"new_content,omitempty"`
+}
+
+// WriteResponseMetadata represents the metadata for a write tool response.
+type WriteResponseMetadata struct {
+	Diff      string `json:"diff"`
+	Additions int    `json:"additions"`
+	Removals  int    `json:"removals"`
+}

internal/proto/version.go 🔗

@@ -0,0 +1,9 @@
+package proto
+
+// VersionInfo represents version information about the server.
+type VersionInfo struct {
+	Version   string `json:"version"`
+	Commit    string `json:"commit"`
+	GoVersion string `json:"go_version"`
+	Platform  string `json:"platform"`
+}

internal/pubsub/events.go 🔗

@@ -1,6 +1,9 @@
 package pubsub
 
-import "context"
+import (
+	"context"
+	"encoding/json"
+)
 
 const (
 	CreatedEvent EventType = "created"
@@ -8,20 +11,43 @@ const (
 	DeletedEvent EventType = "deleted"
 )
 
+// PayloadType identifies the type of event payload for discriminated
+// deserialization over JSON.
+type PayloadType = string
+
+const (
+	PayloadTypeLSPEvent               PayloadType = "lsp_event"
+	PayloadTypeMCPEvent               PayloadType = "mcp_event"
+	PayloadTypePermissionRequest      PayloadType = "permission_request"
+	PayloadTypePermissionNotification PayloadType = "permission_notification"
+	PayloadTypeMessage                PayloadType = "message"
+	PayloadTypeSession                PayloadType = "session"
+	PayloadTypeFile                   PayloadType = "file"
+	PayloadTypeAgentEvent             PayloadType = "agent_event"
+)
+
+// Payload wraps a discriminated JSON payload with a type tag.
+type Payload struct {
+	Type    PayloadType     `json:"type"`
+	Payload json.RawMessage `json:"payload"`
+}
+
+// Subscriber can subscribe to events of type T.
 type Subscriber[T any] interface {
 	Subscribe(context.Context) <-chan Event[T]
 }
 
 type (
-	// EventType identifies the type of event
+	// EventType identifies the type of event.
 	EventType string
 
-	// Event represents an event in the lifecycle of a resource
+	// Event represents an event in the lifecycle of a resource.
 	Event[T any] struct {
-		Type    EventType
-		Payload T
+		Type    EventType `json:"type"`
+		Payload T         `json:"payload"`
 	}
 
+	// Publisher can publish events of type T.
 	Publisher[T any] interface {
 		Publish(EventType, T)
 	}

internal/server/config.go 🔗

@@ -0,0 +1,467 @@
+package server
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"github.com/charmbracelet/crush/internal/proto"
+)
+
+// handlePostWorkspaceConfigSet sets a configuration field.
+//
+//	@Summary		Set a config field
+//	@Tags			config
+//	@Accept			json
+//	@Param			id		path	string					true	"Workspace ID"
+//	@Param			request	body	proto.ConfigSetRequest	true	"Config set request"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/config/set [post]
+func (c *controllerV1) handlePostWorkspaceConfigSet(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.ConfigSetRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	if err := c.backend.SetConfigField(id, req.Scope, req.Key, req.Value); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceConfigRemove removes a configuration field.
+//
+//	@Summary		Remove a config field
+//	@Tags			config
+//	@Accept			json
+//	@Param			id		path	string						true	"Workspace ID"
+//	@Param			request	body	proto.ConfigRemoveRequest	true	"Config remove request"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/config/remove [post]
+func (c *controllerV1) handlePostWorkspaceConfigRemove(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.ConfigRemoveRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	if err := c.backend.RemoveConfigField(id, req.Scope, req.Key); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceConfigModel updates the preferred model.
+//
+//	@Summary		Set the preferred model
+//	@Tags			config
+//	@Accept			json
+//	@Param			id		path	string						true	"Workspace ID"
+//	@Param			request	body	proto.ConfigModelRequest	true	"Config model request"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/config/model [post]
+func (c *controllerV1) handlePostWorkspaceConfigModel(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.ConfigModelRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	if err := c.backend.UpdatePreferredModel(id, req.Scope, req.ModelType, req.Model); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceConfigCompact sets compact mode.
+//
+//	@Summary		Set compact mode
+//	@Tags			config
+//	@Accept			json
+//	@Param			id		path	string						true	"Workspace ID"
+//	@Param			request	body	proto.ConfigCompactRequest	true	"Config compact request"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/config/compact [post]
+func (c *controllerV1) handlePostWorkspaceConfigCompact(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.ConfigCompactRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	if err := c.backend.SetCompactMode(id, req.Scope, req.Enabled); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceConfigProviderKey sets a provider API key.
+//
+//	@Summary		Set provider API key
+//	@Tags			config
+//	@Accept			json
+//	@Param			id		path	string							true	"Workspace ID"
+//	@Param			request	body	proto.ConfigProviderKeyRequest	true	"Config provider key request"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/config/provider-key [post]
+func (c *controllerV1) handlePostWorkspaceConfigProviderKey(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.ConfigProviderKeyRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	if err := c.backend.SetProviderAPIKey(id, req.Scope, req.ProviderID, req.APIKey); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceConfigImportCopilot imports Copilot credentials.
+//
+//	@Summary		Import Copilot credentials
+//	@Tags			config
+//	@Produce		json
+//	@Param			id	path		string						true	"Workspace ID"
+//	@Success		200	{object}	proto.ImportCopilotResponse
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/config/import-copilot [post]
+func (c *controllerV1) handlePostWorkspaceConfigImportCopilot(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	token, ok, err := c.backend.ImportCopilot(id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, proto.ImportCopilotResponse{Token: token, Success: ok})
+}
+
+// handlePostWorkspaceConfigRefreshOAuth refreshes an OAuth token for a provider.
+//
+//	@Summary		Refresh OAuth token
+//	@Tags			config
+//	@Accept			json
+//	@Param			id		path	string							true	"Workspace ID"
+//	@Param			request	body	proto.ConfigRefreshOAuthRequest	true	"Refresh OAuth request"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/config/refresh-oauth [post]
+func (c *controllerV1) handlePostWorkspaceConfigRefreshOAuth(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.ConfigRefreshOAuthRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	if err := c.backend.RefreshOAuthToken(r.Context(), id, req.Scope, req.ProviderID); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceProjectNeedsInit reports whether a project needs initialization.
+//
+//	@Summary		Check if project needs initialization
+//	@Tags			project
+//	@Produce		json
+//	@Param			id	path		string							true	"Workspace ID"
+//	@Success		200	{object}	proto.ProjectNeedsInitResponse
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/project/needs-init [get]
+func (c *controllerV1) handleGetWorkspaceProjectNeedsInit(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	needs, err := c.backend.ProjectNeedsInitialization(id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, proto.ProjectNeedsInitResponse{NeedsInit: needs})
+}
+
+// handlePostWorkspaceProjectInit marks the project as initialized.
+//
+//	@Summary		Mark project as initialized
+//	@Tags			project
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/project/init [post]
+func (c *controllerV1) handlePostWorkspaceProjectInit(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := c.backend.MarkProjectInitialized(id); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceProjectInitPrompt returns the project initialization prompt.
+//
+//	@Summary		Get project initialization prompt
+//	@Tags			project
+//	@Produce		json
+//	@Param			id	path		string							true	"Workspace ID"
+//	@Success		200	{object}	proto.ProjectInitPromptResponse
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/project/init-prompt [get]
+func (c *controllerV1) handleGetWorkspaceProjectInitPrompt(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	prompt, err := c.backend.InitializePrompt(id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, proto.ProjectInitPromptResponse{Prompt: prompt})
+}
+
+// handlePostWorkspaceMCPEnableDocker enables the Docker MCP server.
+//
+//	@Summary		Enable Docker MCP
+//	@Tags			mcp
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/mcp/docker/enable [post]
+func (c *controllerV1) handlePostWorkspaceMCPEnableDocker(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := c.backend.EnableDockerMCP(r.Context(), id); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceMCPDisableDocker disables the Docker MCP server.
+//
+//	@Summary		Disable Docker MCP
+//	@Tags			mcp
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/mcp/docker/disable [post]
+func (c *controllerV1) handlePostWorkspaceMCPDisableDocker(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := c.backend.DisableDockerMCP(id); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceMCPRefreshTools refreshes tools for a named MCP server.
+//
+//	@Summary		Refresh MCP tools
+//	@Tags			mcp
+//	@Accept			json
+//	@Param			id		path	string					true	"Workspace ID"
+//	@Param			request	body	proto.MCPNameRequest	true	"MCP name request"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/mcp/refresh-tools [post]
+func (c *controllerV1) handlePostWorkspaceMCPRefreshTools(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.MCPNameRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	if err := c.backend.RefreshMCPTools(r.Context(), id, req.Name); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceMCPReadResource reads a resource from an MCP server.
+//
+//	@Summary		Read MCP resource
+//	@Tags			mcp
+//	@Accept			json
+//	@Produce		json
+//	@Param			id		path		string						true	"Workspace ID"
+//	@Param			request	body		proto.MCPReadResourceRequest	true	"MCP read resource request"
+//	@Success		200		{object}	object
+//	@Failure		400		{object}	proto.Error
+//	@Failure		404		{object}	proto.Error
+//	@Failure		500		{object}	proto.Error
+//	@Router			/workspaces/{id}/mcp/read-resource [post]
+func (c *controllerV1) handlePostWorkspaceMCPReadResource(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.MCPReadResourceRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	contents, err := c.backend.ReadMCPResource(r.Context(), id, req.Name, req.URI)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, contents)
+}
+
+// handlePostWorkspaceMCPGetPrompt retrieves a prompt from an MCP server.
+//
+//	@Summary		Get MCP prompt
+//	@Tags			mcp
+//	@Accept			json
+//	@Produce		json
+//	@Param			id		path		string						true	"Workspace ID"
+//	@Param			request	body		proto.MCPGetPromptRequest	true	"MCP get prompt request"
+//	@Success		200		{object}	proto.MCPGetPromptResponse
+//	@Failure		400		{object}	proto.Error
+//	@Failure		404		{object}	proto.Error
+//	@Failure		500		{object}	proto.Error
+//	@Router			/workspaces/{id}/mcp/get-prompt [post]
+func (c *controllerV1) handlePostWorkspaceMCPGetPrompt(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.MCPGetPromptRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	prompt, err := c.backend.GetMCPPrompt(id, req.ClientID, req.PromptID, req.Args)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, proto.MCPGetPromptResponse{Prompt: prompt})
+}
+
+// handleGetWorkspaceMCPStates returns the state of all MCP clients.
+//
+//	@Summary		Get MCP client states
+//	@Tags			mcp
+//	@Produce		json
+//	@Param			id	path		string						true	"Workspace ID"
+//	@Success		200	{object}	map[string]proto.MCPClientInfo
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/mcp/states [get]
+func (c *controllerV1) handleGetWorkspaceMCPStates(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	states := c.backend.MCPGetStates(id)
+	result := make(map[string]proto.MCPClientInfo, len(states))
+	for k, v := range states {
+		result[k] = proto.MCPClientInfo{
+			Name:          v.Name,
+			State:         proto.MCPState(v.State),
+			Error:         v.Error,
+			ToolCount:     v.Counts.Tools,
+			PromptCount:   v.Counts.Prompts,
+			ResourceCount: v.Counts.Resources,
+			ConnectedAt:   v.ConnectedAt,
+		}
+	}
+	jsonEncode(w, result)
+}
+
+// handlePostWorkspaceMCPRefreshPrompts refreshes prompts for a named MCP server.
+//
+//	@Summary		Refresh MCP prompts
+//	@Tags			mcp
+//	@Accept			json
+//	@Param			id		path	string					true	"Workspace ID"
+//	@Param			request	body	proto.MCPNameRequest	true	"MCP name request"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/mcp/refresh-prompts [post]
+func (c *controllerV1) handlePostWorkspaceMCPRefreshPrompts(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.MCPNameRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	c.backend.MCPRefreshPrompts(r.Context(), id, req.Name)
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceMCPRefreshResources refreshes resources for a named MCP server.
+//
+//	@Summary		Refresh MCP resources
+//	@Tags			mcp
+//	@Accept			json
+//	@Param			id		path	string					true	"Workspace ID"
+//	@Param			request	body	proto.MCPNameRequest	true	"MCP name request"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/mcp/refresh-resources [post]
+func (c *controllerV1) handlePostWorkspaceMCPRefreshResources(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.MCPNameRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	c.backend.MCPRefreshResources(r.Context(), id, req.Name)
+	w.WriteHeader(http.StatusOK)
+}

internal/server/events.go 🔗

@@ -0,0 +1,214 @@
+package server
+
+import (
+	"encoding/json"
+	"fmt"
+	"log/slog"
+
+	"github.com/charmbracelet/crush/internal/agent/notify"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/proto"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/session"
+)
+
+// wrapEvent converts a raw tea.Msg (a pubsub.Event[T] from the app
+// event fan-in) into a pubsub.Payload envelope with the correct
+// PayloadType discriminator and a proto-typed inner payload that has
+// proper JSON tags. Returns nil if the event type is unrecognized.
+func wrapEvent(ev any) *pubsub.Payload {
+	switch e := ev.(type) {
+	case pubsub.Event[app.LSPEvent]:
+		return envelope(pubsub.PayloadTypeLSPEvent, pubsub.Event[proto.LSPEvent]{
+			Type: e.Type,
+			Payload: proto.LSPEvent{
+				Type:            proto.LSPEventType(e.Payload.Type),
+				Name:            e.Payload.Name,
+				State:           e.Payload.State,
+				Error:           e.Payload.Error,
+				DiagnosticCount: e.Payload.DiagnosticCount,
+			},
+		})
+	case pubsub.Event[mcp.Event]:
+		return envelope(pubsub.PayloadTypeMCPEvent, pubsub.Event[proto.MCPEvent]{
+			Type: e.Type,
+			Payload: proto.MCPEvent{
+				Type:      mcpEventTypeToProto(e.Payload.Type),
+				Name:      e.Payload.Name,
+				State:     proto.MCPState(e.Payload.State),
+				Error:     e.Payload.Error,
+				ToolCount: e.Payload.Counts.Tools,
+			},
+		})
+	case pubsub.Event[permission.PermissionRequest]:
+		return envelope(pubsub.PayloadTypePermissionRequest, pubsub.Event[proto.PermissionRequest]{
+			Type: e.Type,
+			Payload: proto.PermissionRequest{
+				ID:          e.Payload.ID,
+				SessionID:   e.Payload.SessionID,
+				ToolCallID:  e.Payload.ToolCallID,
+				ToolName:    e.Payload.ToolName,
+				Description: e.Payload.Description,
+				Action:      e.Payload.Action,
+				Path:        e.Payload.Path,
+				Params:      e.Payload.Params,
+			},
+		})
+	case pubsub.Event[permission.PermissionNotification]:
+		return envelope(pubsub.PayloadTypePermissionNotification, pubsub.Event[proto.PermissionNotification]{
+			Type: e.Type,
+			Payload: proto.PermissionNotification{
+				ToolCallID: e.Payload.ToolCallID,
+				Granted:    e.Payload.Granted,
+				Denied:     e.Payload.Denied,
+			},
+		})
+	case pubsub.Event[message.Message]:
+		return envelope(pubsub.PayloadTypeMessage, pubsub.Event[proto.Message]{
+			Type:    e.Type,
+			Payload: messageToProto(e.Payload),
+		})
+	case pubsub.Event[session.Session]:
+		return envelope(pubsub.PayloadTypeSession, pubsub.Event[proto.Session]{
+			Type:    e.Type,
+			Payload: sessionToProto(e.Payload),
+		})
+	case pubsub.Event[history.File]:
+		return envelope(pubsub.PayloadTypeFile, pubsub.Event[proto.File]{
+			Type:    e.Type,
+			Payload: fileToProto(e.Payload),
+		})
+	case pubsub.Event[notify.Notification]:
+		return envelope(pubsub.PayloadTypeAgentEvent, pubsub.Event[proto.AgentEvent]{
+			Type: e.Type,
+			Payload: proto.AgentEvent{
+				SessionID:    e.Payload.SessionID,
+				SessionTitle: e.Payload.SessionTitle,
+				Type:         proto.AgentEventType(e.Payload.Type),
+			},
+		})
+	default:
+		slog.Warn("Unrecognized event type for SSE wrapping", "type", fmt.Sprintf("%T", ev))
+		return nil
+	}
+}
+
+// envelope marshals the inner event and wraps it in a pubsub.Payload.
+func envelope(payloadType pubsub.PayloadType, inner any) *pubsub.Payload {
+	raw, err := json.Marshal(inner)
+	if err != nil {
+		slog.Error("Failed to marshal event payload", "error", err)
+		return nil
+	}
+	return &pubsub.Payload{
+		Type:    payloadType,
+		Payload: raw,
+	}
+}
+
+func mcpEventTypeToProto(t mcp.EventType) proto.MCPEventType {
+	switch t {
+	case mcp.EventStateChanged:
+		return proto.MCPEventStateChanged
+	case mcp.EventToolsListChanged:
+		return proto.MCPEventToolsListChanged
+	case mcp.EventPromptsListChanged:
+		return proto.MCPEventPromptsListChanged
+	case mcp.EventResourcesListChanged:
+		return proto.MCPEventResourcesListChanged
+	default:
+		return proto.MCPEventStateChanged
+	}
+}
+
+func sessionToProto(s session.Session) proto.Session {
+	return proto.Session{
+		ID:               s.ID,
+		ParentSessionID:  s.ParentSessionID,
+		Title:            s.Title,
+		SummaryMessageID: s.SummaryMessageID,
+		MessageCount:     s.MessageCount,
+		PromptTokens:     s.PromptTokens,
+		CompletionTokens: s.CompletionTokens,
+		Cost:             s.Cost,
+		CreatedAt:        s.CreatedAt,
+		UpdatedAt:        s.UpdatedAt,
+	}
+}
+
+func fileToProto(f history.File) proto.File {
+	return proto.File{
+		ID:        f.ID,
+		SessionID: f.SessionID,
+		Path:      f.Path,
+		Content:   f.Content,
+		Version:   f.Version,
+		CreatedAt: f.CreatedAt,
+		UpdatedAt: f.UpdatedAt,
+	}
+}
+
+func messageToProto(m message.Message) proto.Message {
+	msg := proto.Message{
+		ID:        m.ID,
+		SessionID: m.SessionID,
+		Role:      proto.MessageRole(m.Role),
+		Model:     m.Model,
+		Provider:  m.Provider,
+		CreatedAt: m.CreatedAt,
+		UpdatedAt: m.UpdatedAt,
+	}
+
+	for _, p := range m.Parts {
+		switch v := p.(type) {
+		case message.TextContent:
+			msg.Parts = append(msg.Parts, proto.TextContent{Text: v.Text})
+		case message.ReasoningContent:
+			msg.Parts = append(msg.Parts, proto.ReasoningContent{
+				Thinking:   v.Thinking,
+				Signature:  v.Signature,
+				StartedAt:  v.StartedAt,
+				FinishedAt: v.FinishedAt,
+			})
+		case message.ToolCall:
+			msg.Parts = append(msg.Parts, proto.ToolCall{
+				ID:       v.ID,
+				Name:     v.Name,
+				Input:    v.Input,
+				Finished: v.Finished,
+			})
+		case message.ToolResult:
+			msg.Parts = append(msg.Parts, proto.ToolResult{
+				ToolCallID: v.ToolCallID,
+				Name:       v.Name,
+				Content:    v.Content,
+				IsError:    v.IsError,
+			})
+		case message.Finish:
+			msg.Parts = append(msg.Parts, proto.Finish{
+				Reason:  proto.FinishReason(v.Reason),
+				Time:    v.Time,
+				Message: v.Message,
+				Details: v.Details,
+			})
+		case message.ImageURLContent:
+			msg.Parts = append(msg.Parts, proto.ImageURLContent{URL: v.URL, Detail: v.Detail})
+		case message.BinaryContent:
+			msg.Parts = append(msg.Parts, proto.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
+		}
+	}
+
+	return msg
+}
+
+func messagesToProto(msgs []message.Message) []proto.Message {
+	out := make([]proto.Message, len(msgs))
+	for i, m := range msgs {
+		out[i] = messageToProto(m)
+	}
+	return out
+}

internal/server/logging.go 🔗

@@ -0,0 +1,51 @@
+package server
+
+import (
+	"log/slog"
+	"net/http"
+	"time"
+)
+
+func (s *Server) loggingHandler(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if s.logger == nil {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		start := time.Now()
+		lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
+		s.logger.Debug("HTTP request",
+			slog.String("method", r.Method),
+			slog.String("path", r.URL.Path),
+			slog.String("remote_addr", r.RemoteAddr),
+			slog.String("user_agent", r.UserAgent()),
+		)
+
+		next.ServeHTTP(lrw, r)
+		duration := time.Since(start)
+
+		s.logger.Debug("HTTP response",
+			slog.String("method", r.Method),
+			slog.String("path", r.URL.Path),
+			slog.Int("status", lrw.statusCode),
+			slog.Duration("duration", duration),
+			slog.String("remote_addr", r.RemoteAddr),
+			slog.String("user_agent", r.UserAgent()),
+		)
+	})
+}
+
+type loggingResponseWriter struct {
+	http.ResponseWriter
+	statusCode int
+}
+
+func (lrw *loggingResponseWriter) WriteHeader(code int) {
+	lrw.statusCode = code
+	lrw.ResponseWriter.WriteHeader(code)
+}
+
+func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
+	return lrw.ResponseWriter
+}

internal/server/net_other.go 🔗

@@ -0,0 +1,11 @@
+//go:build !windows
+// +build !windows
+
+package server
+
+import "net"
+
+func listen(network, address string) (net.Listener, error) {
+	//nolint:noctx
+	return net.Listen(network, address)
+}

internal/server/net_windows.go 🔗

@@ -0,0 +1,24 @@
+//go:build windows
+// +build windows
+
+package server
+
+import (
+	"net"
+
+	"github.com/Microsoft/go-winio"
+)
+
+func listen(network, address string) (net.Listener, error) {
+	switch network {
+	case "npipe":
+		cfg := &winio.PipeConfig{
+			MessageMode:      true,
+			InputBufferSize:  65536,
+			OutputBufferSize: 65536,
+		}
+		return winio.ListenPipe(address, cfg)
+	default:
+		return net.Listen(network, address) //nolint:noctx
+	}
+}

internal/server/proto.go 🔗

@@ -0,0 +1,969 @@
+package server
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/charmbracelet/crush/internal/backend"
+	"github.com/charmbracelet/crush/internal/proto"
+	"github.com/charmbracelet/crush/internal/session"
+)
+
+type controllerV1 struct {
+	backend *backend.Backend
+	server  *Server
+}
+
+// handleGetHealth checks server health.
+//
+//	@Summary		Health check
+//	@Tags			system
+//	@Success		200
+//	@Router			/health [get]
+func (c *controllerV1) handleGetHealth(w http.ResponseWriter, _ *http.Request) {
+	w.WriteHeader(http.StatusOK)
+}
+
+// handleGetVersion returns server version information.
+//
+//	@Summary		Get server version
+//	@Tags			system
+//	@Produce		json
+//	@Success		200	{object}	proto.VersionInfo
+//	@Router			/version [get]
+func (c *controllerV1) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
+	jsonEncode(w, c.backend.VersionInfo())
+}
+
+// handlePostControl sends a control command to the server.
+//
+//	@Summary		Send server control command
+//	@Tags			system
+//	@Accept			json
+//	@Param			request	body	proto.ServerControl	true	"Control command (e.g. shutdown)"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Router			/control [post]
+func (c *controllerV1) handlePostControl(w http.ResponseWriter, r *http.Request) {
+	var req proto.ServerControl
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	switch req.Command {
+	case "shutdown":
+		c.backend.Shutdown()
+	default:
+		c.server.logError(r, "Unknown command", "command", req.Command)
+		jsonError(w, http.StatusBadRequest, "unknown command")
+		return
+	}
+}
+
+// handleGetConfig returns global server configuration.
+//
+//	@Summary		Get server config
+//	@Tags			system
+//	@Produce		json
+//	@Success		200	{object}	object
+//	@Router			/config [get]
+func (c *controllerV1) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
+	jsonEncode(w, c.backend.Config())
+}
+
+// handleGetWorkspaces lists all workspaces.
+//
+//	@Summary		List workspaces
+//	@Tags			workspaces
+//	@Produce		json
+//	@Success		200	{array}		proto.Workspace
+//	@Router			/workspaces [get]
+func (c *controllerV1) handleGetWorkspaces(w http.ResponseWriter, _ *http.Request) {
+	jsonEncode(w, c.backend.ListWorkspaces())
+}
+
+// handleGetWorkspace returns a single workspace by ID.
+//
+//	@Summary		Get workspace
+//	@Tags			workspaces
+//	@Produce		json
+//	@Param			id	path		string	true	"Workspace ID"
+//	@Success		200	{object}	proto.Workspace
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id} [get]
+func (c *controllerV1) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	ws, err := c.backend.GetWorkspaceProto(id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, ws)
+}
+
+// handlePostWorkspaces creates a new workspace.
+//
+//	@Summary		Create workspace
+//	@Tags			workspaces
+//	@Accept			json
+//	@Produce		json
+//	@Param			request	body		proto.Workspace	true	"Workspace creation params"
+//	@Success		200		{object}	proto.Workspace
+//	@Failure		400		{object}	proto.Error
+//	@Failure		500		{object}	proto.Error
+//	@Router			/workspaces [post]
+func (c *controllerV1) handlePostWorkspaces(w http.ResponseWriter, r *http.Request) {
+	var args proto.Workspace
+	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	_, result, err := c.backend.CreateWorkspace(args)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, result)
+}
+
+// handleDeleteWorkspaces deletes a workspace.
+//
+//	@Summary		Delete workspace
+//	@Tags			workspaces
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Router			/workspaces/{id} [delete]
+func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	c.backend.DeleteWorkspace(id)
+}
+
+// handleGetWorkspaceConfig returns workspace configuration.
+//
+//	@Summary		Get workspace config
+//	@Tags			workspaces
+//	@Produce		json
+//	@Param			id	path		string	true	"Workspace ID"
+//	@Success		200	{object}	object
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/config [get]
+func (c *controllerV1) handleGetWorkspaceConfig(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	cfg, err := c.backend.GetWorkspaceConfig(id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, cfg)
+}
+
+// handleGetWorkspaceProviders lists available providers for a workspace.
+//
+//	@Summary		Get workspace providers
+//	@Tags			workspaces
+//	@Produce		json
+//	@Param			id	path		string	true	"Workspace ID"
+//	@Success		200	{object}	object
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/providers [get]
+func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	providers, err := c.backend.GetWorkspaceProviders(id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, providers)
+}
+
+// handleGetWorkspaceEvents streams workspace events as Server-Sent Events.
+//
+//	@Summary		Stream workspace events (SSE)
+//	@Tags			workspaces
+//	@Produce		text/event-stream
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/events [get]
+func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) {
+	flusher := http.NewResponseController(w)
+	id := r.PathValue("id")
+	events, err := c.backend.SubscribeEvents(id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+
+	for {
+		select {
+		case <-r.Context().Done():
+			c.server.logDebug(r, "Stopping event stream")
+			return
+		case ev, ok := <-events:
+			if !ok {
+				return
+			}
+			c.server.logDebug(r, "Sending event", "event", fmt.Sprintf("%T %+v", ev, ev))
+			wrapped := wrapEvent(ev)
+			if wrapped == nil {
+				continue
+			}
+			data, err := json.Marshal(wrapped)
+			if err != nil {
+				c.server.logError(r, "Failed to marshal event", "error", err)
+				continue
+			}
+
+			fmt.Fprintf(w, "data: %s\n\n", data)
+			flusher.Flush()
+		}
+	}
+}
+
+// handleGetWorkspaceLSPs lists LSP clients for a workspace.
+//
+//	@Summary		List LSP clients
+//	@Tags			lsp
+//	@Produce		json
+//	@Param			id	path		string							true	"Workspace ID"
+//	@Success		200	{object}	map[string]proto.LSPClientInfo
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/lsps [get]
+func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	states, err := c.backend.GetLSPStates(id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	result := make(map[string]proto.LSPClientInfo, len(states))
+	for k, v := range states {
+		result[k] = proto.LSPClientInfo{
+			Name:            v.Name,
+			State:           v.State,
+			Error:           v.Error,
+			DiagnosticCount: v.DiagnosticCount,
+			ConnectedAt:     v.ConnectedAt,
+		}
+	}
+	jsonEncode(w, result)
+}
+
+// handleGetWorkspaceLSPDiagnostics returns diagnostics for an LSP client.
+//
+//	@Summary		Get LSP diagnostics
+//	@Tags			lsp
+//	@Produce		json
+//	@Param			id	path		string	true	"Workspace ID"
+//	@Param			lsp	path		string	true	"LSP client name"
+//	@Success		200	{object}	object
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/lsps/{lsp}/diagnostics [get]
+func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	lspName := r.PathValue("lsp")
+	diagnostics, err := c.backend.GetLSPDiagnostics(id, lspName)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, diagnostics)
+}
+
+// handleGetWorkspaceSessions lists sessions for a workspace.
+//
+//	@Summary		List sessions
+//	@Tags			sessions
+//	@Produce		json
+//	@Param			id	path		string			true	"Workspace ID"
+//	@Success		200	{array}		proto.Session
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/sessions [get]
+func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sessions, err := c.backend.ListSessions(r.Context(), id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	result := make([]proto.Session, len(sessions))
+	for i, s := range sessions {
+		result[i] = sessionToProto(s)
+	}
+	jsonEncode(w, result)
+}
+
+// handlePostWorkspaceSessions creates a new session in a workspace.
+//
+//	@Summary		Create session
+//	@Tags			sessions
+//	@Accept			json
+//	@Produce		json
+//	@Param			id		path		string			true	"Workspace ID"
+//	@Param			request	body		proto.Session	true	"Session creation params (title)"
+//	@Success		200		{object}	proto.Session
+//	@Failure		400		{object}	proto.Error
+//	@Failure		404		{object}	proto.Error
+//	@Failure		500		{object}	proto.Error
+//	@Router			/workspaces/{id}/sessions [post]
+func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var args session.Session
+	if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	sess, err := c.backend.CreateSession(r.Context(), id, args.Title)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, sessionToProto(sess))
+}
+
+// handleGetWorkspaceSession returns a single session.
+//
+//	@Summary		Get session
+//	@Tags			sessions
+//	@Produce		json
+//	@Param			id	path		string	true	"Workspace ID"
+//	@Param			sid	path		string	true	"Session ID"
+//	@Success		200	{object}	proto.Session
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/sessions/{sid} [get]
+func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.PathValue("sid")
+	sess, err := c.backend.GetSession(r.Context(), id, sid)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, sessionToProto(sess))
+}
+
+// handleGetWorkspaceSessionHistory returns the history for a session.
+//
+//	@Summary		Get session history
+//	@Tags			sessions
+//	@Produce		json
+//	@Param			id	path		string		true	"Workspace ID"
+//	@Param			sid	path		string		true	"Session ID"
+//	@Success		200	{array}		proto.File
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/sessions/{sid}/history [get]
+func (c *controllerV1) handleGetWorkspaceSessionHistory(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.PathValue("sid")
+	history, err := c.backend.ListSessionHistory(r.Context(), id, sid)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, history)
+}
+
+// handleGetWorkspaceSessionMessages returns all messages for a session.
+//
+//	@Summary		Get session messages
+//	@Tags			sessions
+//	@Produce		json
+//	@Param			id	path		string			true	"Workspace ID"
+//	@Param			sid	path		string			true	"Session ID"
+//	@Success		200	{array}		proto.Message
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/sessions/{sid}/messages [get]
+func (c *controllerV1) handleGetWorkspaceSessionMessages(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.PathValue("sid")
+	messages, err := c.backend.ListSessionMessages(r.Context(), id, sid)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, messagesToProto(messages))
+}
+
+// handlePutWorkspaceSession updates a session.
+//
+//	@Summary		Update session
+//	@Tags			sessions
+//	@Accept			json
+//	@Produce		json
+//	@Param			id		path		string			true	"Workspace ID"
+//	@Param			sid		path		string			true	"Session ID"
+//	@Param			request	body		proto.Session	true	"Updated session"
+//	@Success		200		{object}	proto.Session
+//	@Failure		400		{object}	proto.Error
+//	@Failure		404		{object}	proto.Error
+//	@Failure		500		{object}	proto.Error
+//	@Router			/workspaces/{id}/sessions/{sid} [put]
+func (c *controllerV1) handlePutWorkspaceSession(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var sess session.Session
+	if err := json.NewDecoder(r.Body).Decode(&sess); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	saved, err := c.backend.SaveSession(r.Context(), id, sess)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, sessionToProto(saved))
+}
+
+// handleDeleteWorkspaceSession deletes a session.
+//
+//	@Summary		Delete session
+//	@Tags			sessions
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Param			sid	path	string	true	"Session ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/sessions/{sid} [delete]
+func (c *controllerV1) handleDeleteWorkspaceSession(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.PathValue("sid")
+	if err := c.backend.DeleteSession(r.Context(), id, sid); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceSessionUserMessages returns user messages for a session.
+//
+//	@Summary		Get user messages for session
+//	@Tags			sessions
+//	@Produce		json
+//	@Param			id	path		string			true	"Workspace ID"
+//	@Param			sid	path		string			true	"Session ID"
+//	@Success		200	{array}		proto.Message
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/sessions/{sid}/messages/user [get]
+func (c *controllerV1) handleGetWorkspaceSessionUserMessages(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.PathValue("sid")
+	messages, err := c.backend.ListUserMessages(r.Context(), id, sid)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, messagesToProto(messages))
+}
+
+// handleGetWorkspaceAllUserMessages returns all user messages across sessions.
+//
+//	@Summary		Get all user messages for workspace
+//	@Tags			workspaces
+//	@Produce		json
+//	@Param			id	path		string			true	"Workspace ID"
+//	@Success		200	{array}		proto.Message
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/messages/user [get]
+func (c *controllerV1) handleGetWorkspaceAllUserMessages(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	messages, err := c.backend.ListAllUserMessages(r.Context(), id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, messagesToProto(messages))
+}
+
+// handleGetWorkspaceSessionFileTrackerFiles lists files read in a session.
+//
+//	@Summary		List tracked files for session
+//	@Tags			filetracker
+//	@Produce		json
+//	@Param			id	path		string		true	"Workspace ID"
+//	@Param			sid	path		string		true	"Session ID"
+//	@Success		200	{array}		string
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/sessions/{sid}/filetracker/files [get]
+func (c *controllerV1) handleGetWorkspaceSessionFileTrackerFiles(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.PathValue("sid")
+	files, err := c.backend.FileTrackerListReadFiles(r.Context(), id, sid)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, files)
+}
+
+// handlePostWorkspaceFileTrackerRead records a file read event.
+//
+//	@Summary		Record file read
+//	@Tags			filetracker
+//	@Accept			json
+//	@Param			id		path	string							true	"Workspace ID"
+//	@Param			request	body	proto.FileTrackerReadRequest	true	"File tracker read request"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/filetracker/read [post]
+func (c *controllerV1) handlePostWorkspaceFileTrackerRead(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.FileTrackerReadRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	if err := c.backend.FileTrackerRecordRead(r.Context(), id, req.SessionID, req.Path); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceFileTrackerLastRead returns the last read time for a file.
+//
+//	@Summary		Get last read time for file
+//	@Tags			filetracker
+//	@Produce		json
+//	@Param			id			path		string	true	"Workspace ID"
+//	@Param			session_id	query		string	false	"Session ID"
+//	@Param			path		query		string	true	"File path"
+//	@Success		200			{object}	object
+//	@Failure		404			{object}	proto.Error
+//	@Failure		500			{object}	proto.Error
+//	@Router			/workspaces/{id}/filetracker/lastread [get]
+func (c *controllerV1) handleGetWorkspaceFileTrackerLastRead(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.URL.Query().Get("session_id")
+	path := r.URL.Query().Get("path")
+
+	t, err := c.backend.FileTrackerLastReadTime(r.Context(), id, sid, path)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, t)
+}
+
+// handlePostWorkspaceLSPStart starts an LSP server for a path.
+//
+//	@Summary		Start LSP server
+//	@Tags			lsp
+//	@Accept			json
+//	@Param			id		path	string					true	"Workspace ID"
+//	@Param			request	body	proto.LSPStartRequest	true	"LSP start request"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/lsps/start [post]
+func (c *controllerV1) handlePostWorkspaceLSPStart(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.LSPStartRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	if err := c.backend.LSPStart(r.Context(), id, req.Path); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceLSPStopAll stops all LSP servers.
+//
+//	@Summary		Stop all LSP servers
+//	@Tags			lsp
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/lsps/stop [post]
+func (c *controllerV1) handlePostWorkspaceLSPStopAll(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := c.backend.LSPStopAll(r.Context(), id); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceAgent returns agent info for a workspace.
+//
+//	@Summary		Get agent info
+//	@Tags			agent
+//	@Produce		json
+//	@Param			id	path		string			true	"Workspace ID"
+//	@Success		200	{object}	proto.AgentInfo
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/agent [get]
+func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	info, err := c.backend.GetAgentInfo(id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, info)
+}
+
+// handlePostWorkspaceAgent sends a message to the agent.
+//
+//	@Summary		Send message to agent
+//	@Tags			agent
+//	@Accept			json
+//	@Param			id		path	string				true	"Workspace ID"
+//	@Param			request	body	proto.AgentMessage	true	"Agent message"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/agent [post]
+func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var msg proto.AgentMessage
+	if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	if err := c.backend.SendMessage(r.Context(), id, msg); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceAgentInit initializes the agent for a workspace.
+//
+//	@Summary		Initialize agent
+//	@Tags			agent
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/agent/init [post]
+func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := c.backend.InitAgent(r.Context(), id); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceAgentUpdate updates the agent for a workspace.
+//
+//	@Summary		Update agent
+//	@Tags			agent
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/agent/update [post]
+func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	if err := c.backend.UpdateAgent(r.Context(), id); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceAgentSession returns a specific agent session.
+//
+//	@Summary		Get agent session
+//	@Tags			agent
+//	@Produce		json
+//	@Param			id	path		string				true	"Workspace ID"
+//	@Param			sid	path		string				true	"Session ID"
+//	@Success		200	{object}	proto.AgentSession
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/agent/sessions/{sid} [get]
+func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.PathValue("sid")
+	agentSession, err := c.backend.GetAgentSession(r.Context(), id, sid)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, agentSession)
+}
+
+// handlePostWorkspaceAgentSessionCancel cancels a running agent session.
+//
+//	@Summary		Cancel agent session
+//	@Tags			agent
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Param			sid	path	string	true	"Session ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/agent/sessions/{sid}/cancel [post]
+func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.PathValue("sid")
+	if err := c.backend.CancelSession(id, sid); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceAgentSessionPromptQueued returns whether a queued prompt exists.
+//
+//	@Summary		Get queued prompt status
+//	@Tags			agent
+//	@Produce		json
+//	@Param			id	path		string	true	"Workspace ID"
+//	@Param			sid	path		string	true	"Session ID"
+//	@Success		200	{object}	object
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/agent/sessions/{sid}/prompts/queued [get]
+func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.PathValue("sid")
+	queued, err := c.backend.QueuedPrompts(id, sid)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, queued)
+}
+
+// handlePostWorkspaceAgentSessionPromptClear clears the prompt queue for a session.
+//
+//	@Summary		Clear prompt queue
+//	@Tags			agent
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Param			sid	path	string	true	"Session ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/agent/sessions/{sid}/prompts/clear [post]
+func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.PathValue("sid")
+	if err := c.backend.ClearQueue(id, sid); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspaceAgentSessionSummarize summarizes a session.
+//
+//	@Summary		Summarize session
+//	@Tags			agent
+//	@Param			id	path	string	true	"Workspace ID"
+//	@Param			sid	path	string	true	"Session ID"
+//	@Success		200
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/agent/sessions/{sid}/summarize [post]
+func (c *controllerV1) handlePostWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.PathValue("sid")
+	if err := c.backend.SummarizeSession(r.Context(), id, sid); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handleGetWorkspaceAgentSessionPromptList returns the list of queued prompts.
+//
+//	@Summary		List queued prompts
+//	@Tags			agent
+//	@Produce		json
+//	@Param			id	path		string		true	"Workspace ID"
+//	@Param			sid	path		string		true	"Session ID"
+//	@Success		200	{array}		string
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/agent/sessions/{sid}/prompts/list [get]
+func (c *controllerV1) handleGetWorkspaceAgentSessionPromptList(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	sid := r.PathValue("sid")
+	prompts, err := c.backend.QueuedPromptsList(id, sid)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, prompts)
+}
+
+// handleGetWorkspaceAgentDefaultSmallModel returns the default small model for a provider.
+//
+//	@Summary		Get default small model
+//	@Tags			agent
+//	@Produce		json
+//	@Param			id			path		string	true	"Workspace ID"
+//	@Param			provider_id	query		string	false	"Provider ID"
+//	@Success		200			{object}	object
+//	@Failure		404			{object}	proto.Error
+//	@Failure		500			{object}	proto.Error
+//	@Router			/workspaces/{id}/agent/default-small-model [get]
+func (c *controllerV1) handleGetWorkspaceAgentDefaultSmallModel(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	providerID := r.URL.Query().Get("provider_id")
+	model, err := c.backend.GetDefaultSmallModel(id, providerID)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, model)
+}
+
+// handlePostWorkspacePermissionsGrant grants a permission request.
+//
+//	@Summary		Grant permission
+//	@Tags			permissions
+//	@Accept			json
+//	@Param			id		path	string				true	"Workspace ID"
+//	@Param			request	body	proto.PermissionGrant	true	"Permission grant"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/permissions/grant [post]
+func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.PermissionGrant
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	if err := c.backend.GrantPermission(id, req); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	w.WriteHeader(http.StatusOK)
+}
+
+// handlePostWorkspacePermissionsSkip sets whether to skip permission prompts.
+//
+//	@Summary		Set skip permissions
+//	@Tags			permissions
+//	@Accept			json
+//	@Param			id		path	string						true	"Workspace ID"
+//	@Param			request	body	proto.PermissionSkipRequest	true	"Permission skip request"
+//	@Success		200
+//	@Failure		400	{object}	proto.Error
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/permissions/skip [post]
+func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+
+	var req proto.PermissionSkipRequest
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		c.server.logError(r, "Failed to decode request", "error", err)
+		jsonError(w, http.StatusBadRequest, "failed to decode request")
+		return
+	}
+
+	if err := c.backend.SetPermissionsSkip(id, req.Skip); err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+}
+
+// handleGetWorkspacePermissionsSkip returns whether permission prompts are skipped.
+//
+//	@Summary		Get skip permissions status
+//	@Tags			permissions
+//	@Produce		json
+//	@Param			id	path		string						true	"Workspace ID"
+//	@Success		200	{object}	proto.PermissionSkipRequest
+//	@Failure		404	{object}	proto.Error
+//	@Failure		500	{object}	proto.Error
+//	@Router			/workspaces/{id}/permissions/skip [get]
+func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
+	id := r.PathValue("id")
+	skip, err := c.backend.GetPermissionsSkip(id)
+	if err != nil {
+		c.handleError(w, r, err)
+		return
+	}
+	jsonEncode(w, proto.PermissionSkipRequest{Skip: skip})
+}
+
+// handleError maps backend errors to HTTP status codes and writes the
+// JSON error response.
+func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err error) {
+	status := http.StatusInternalServerError
+	switch {
+	case errors.Is(err, backend.ErrWorkspaceNotFound):
+		status = http.StatusNotFound
+	case errors.Is(err, backend.ErrLSPClientNotFound):
+		status = http.StatusNotFound
+	case errors.Is(err, backend.ErrAgentNotInitialized):
+		status = http.StatusBadRequest
+	case errors.Is(err, backend.ErrPathRequired):
+		status = http.StatusBadRequest
+	case errors.Is(err, backend.ErrInvalidPermissionAction):
+		status = http.StatusBadRequest
+	case errors.Is(err, backend.ErrUnknownCommand):
+		status = http.StatusBadRequest
+	}
+	c.server.logError(r, err.Error())
+	jsonError(w, status, err.Error())
+}
+
+func jsonEncode(w http.ResponseWriter, v any) {
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(v)
+}
+
+func jsonError(w http.ResponseWriter, status int, message string) {
+	w.Header().Set("Content-Type", "application/json")
+	w.WriteHeader(status)
+	_ = json.NewEncoder(w).Encode(proto.Error{Message: message})
+}

internal/server/server.go 🔗

@@ -0,0 +1,234 @@
+package server
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"net"
+	"net/http"
+	"net/url"
+	"os/user"
+	"runtime"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/backend"
+	"github.com/charmbracelet/crush/internal/config"
+	_ "github.com/charmbracelet/crush/internal/swagger"
+	httpswagger "github.com/swaggo/http-swagger/v2"
+)
+
+// ErrServerClosed is returned when the server is closed.
+var ErrServerClosed = http.ErrServerClosed
+
+// ParseHostURL parses a host URL into a [url.URL].
+func ParseHostURL(host string) (*url.URL, error) {
+	proto, addr, ok := strings.Cut(host, "://")
+	if !ok {
+		return nil, fmt.Errorf("invalid host format: %s", host)
+	}
+
+	var basePath string
+	if proto == "tcp" {
+		parsed, err := url.Parse("tcp://" + addr)
+		if err != nil {
+			return nil, fmt.Errorf("invalid tcp address: %v", err)
+		}
+		addr = parsed.Host
+		basePath = parsed.Path
+	}
+	return &url.URL{
+		Scheme: proto,
+		Host:   addr,
+		Path:   basePath,
+	}, nil
+}
+
+// DefaultHost returns the default server host.
+func DefaultHost() string {
+	sock := "crush.sock"
+	usr, err := user.Current()
+	if err == nil && usr.Uid != "" {
+		sock = fmt.Sprintf("crush-%s.sock", usr.Uid)
+	}
+	if runtime.GOOS == "windows" {
+		return fmt.Sprintf("npipe:////./pipe/%s", sock)
+	}
+	return fmt.Sprintf("unix:///tmp/%s", sock)
+}
+
+// Server represents a Crush server bound to a specific address.
+type Server struct {
+	// Addr can be a TCP address, a Unix socket path, or a Windows named pipe.
+	Addr    string
+	network string
+
+	h  *http.Server
+	ln net.Listener
+
+	backend *backend.Backend
+	logger  *slog.Logger
+}
+
+// SetLogger sets the logger for the server.
+func (s *Server) SetLogger(logger *slog.Logger) {
+	s.logger = logger
+}
+
+// DefaultServer returns a new [Server] with the default address.
+func DefaultServer(cfg *config.ConfigStore) *Server {
+	hostURL, err := ParseHostURL(DefaultHost())
+	if err != nil {
+		panic("invalid default host")
+	}
+	return NewServer(cfg, hostURL.Scheme, hostURL.Host)
+}
+
+// NewServer creates a new [Server] with the given network and address.
+func NewServer(cfg *config.ConfigStore, network, address string) *Server {
+	s := new(Server)
+	s.Addr = address
+	s.network = network
+
+	// The backend is created with a shutdown callback that triggers
+	// a graceful server shutdown (e.g. when the last workspace is
+	// removed).
+	s.backend = backend.New(context.Background(), cfg, func() {
+		go func() {
+			slog.Info("Shutting down server...")
+			if err := s.Shutdown(context.Background()); err != nil {
+				slog.Error("Failed to shutdown server", "error", err)
+			}
+		}()
+	})
+
+	var p http.Protocols
+	p.SetHTTP1(true)
+	p.SetUnencryptedHTTP2(true)
+	c := &controllerV1{backend: s.backend, server: s}
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /v1/health", c.handleGetHealth)
+	mux.HandleFunc("GET /v1/version", c.handleGetVersion)
+	mux.HandleFunc("GET /v1/config", c.handleGetConfig)
+	mux.HandleFunc("POST /v1/control", c.handlePostControl)
+	mux.HandleFunc("GET /v1/workspaces", c.handleGetWorkspaces)
+	mux.HandleFunc("POST /v1/workspaces", c.handlePostWorkspaces)
+	mux.HandleFunc("DELETE /v1/workspaces/{id}", c.handleDeleteWorkspaces)
+	mux.HandleFunc("GET /v1/workspaces/{id}", c.handleGetWorkspace)
+	mux.HandleFunc("GET /v1/workspaces/{id}/config", c.handleGetWorkspaceConfig)
+	mux.HandleFunc("GET /v1/workspaces/{id}/events", c.handleGetWorkspaceEvents)
+	mux.HandleFunc("GET /v1/workspaces/{id}/providers", c.handleGetWorkspaceProviders)
+	mux.HandleFunc("GET /v1/workspaces/{id}/sessions", c.handleGetWorkspaceSessions)
+	mux.HandleFunc("POST /v1/workspaces/{id}/sessions", c.handlePostWorkspaceSessions)
+	mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}", c.handleGetWorkspaceSession)
+	mux.HandleFunc("PUT /v1/workspaces/{id}/sessions/{sid}", c.handlePutWorkspaceSession)
+	mux.HandleFunc("DELETE /v1/workspaces/{id}/sessions/{sid}", c.handleDeleteWorkspaceSession)
+	mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/history", c.handleGetWorkspaceSessionHistory)
+	mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/messages", c.handleGetWorkspaceSessionMessages)
+	mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/messages/user", c.handleGetWorkspaceSessionUserMessages)
+	mux.HandleFunc("GET /v1/workspaces/{id}/messages/user", c.handleGetWorkspaceAllUserMessages)
+	mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/filetracker/files", c.handleGetWorkspaceSessionFileTrackerFiles)
+	mux.HandleFunc("POST /v1/workspaces/{id}/filetracker/read", c.handlePostWorkspaceFileTrackerRead)
+	mux.HandleFunc("GET /v1/workspaces/{id}/filetracker/lastread", c.handleGetWorkspaceFileTrackerLastRead)
+	mux.HandleFunc("GET /v1/workspaces/{id}/lsps", c.handleGetWorkspaceLSPs)
+	mux.HandleFunc("GET /v1/workspaces/{id}/lsps/{lsp}/diagnostics", c.handleGetWorkspaceLSPDiagnostics)
+	mux.HandleFunc("POST /v1/workspaces/{id}/lsps/start", c.handlePostWorkspaceLSPStart)
+	mux.HandleFunc("POST /v1/workspaces/{id}/lsps/stop", c.handlePostWorkspaceLSPStopAll)
+	mux.HandleFunc("GET /v1/workspaces/{id}/permissions/skip", c.handleGetWorkspacePermissionsSkip)
+	mux.HandleFunc("POST /v1/workspaces/{id}/permissions/skip", c.handlePostWorkspacePermissionsSkip)
+	mux.HandleFunc("POST /v1/workspaces/{id}/permissions/grant", c.handlePostWorkspacePermissionsGrant)
+	mux.HandleFunc("GET /v1/workspaces/{id}/agent", c.handleGetWorkspaceAgent)
+	mux.HandleFunc("POST /v1/workspaces/{id}/agent", c.handlePostWorkspaceAgent)
+	mux.HandleFunc("POST /v1/workspaces/{id}/agent/init", c.handlePostWorkspaceAgentInit)
+	mux.HandleFunc("POST /v1/workspaces/{id}/agent/update", c.handlePostWorkspaceAgentUpdate)
+	mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}", c.handleGetWorkspaceAgentSession)
+	mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/cancel", c.handlePostWorkspaceAgentSessionCancel)
+	mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/queued", c.handleGetWorkspaceAgentSessionPromptQueued)
+	mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/list", c.handleGetWorkspaceAgentSessionPromptList)
+	mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/prompts/clear", c.handlePostWorkspaceAgentSessionPromptClear)
+	mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/summarize", c.handlePostWorkspaceAgentSessionSummarize)
+	mux.HandleFunc("GET /v1/workspaces/{id}/agent/default-small-model", c.handleGetWorkspaceAgentDefaultSmallModel)
+	mux.HandleFunc("POST /v1/workspaces/{id}/config/set", c.handlePostWorkspaceConfigSet)
+	mux.HandleFunc("POST /v1/workspaces/{id}/config/remove", c.handlePostWorkspaceConfigRemove)
+	mux.HandleFunc("POST /v1/workspaces/{id}/config/model", c.handlePostWorkspaceConfigModel)
+	mux.HandleFunc("POST /v1/workspaces/{id}/config/compact", c.handlePostWorkspaceConfigCompact)
+	mux.HandleFunc("POST /v1/workspaces/{id}/config/provider-key", c.handlePostWorkspaceConfigProviderKey)
+	mux.HandleFunc("POST /v1/workspaces/{id}/config/import-copilot", c.handlePostWorkspaceConfigImportCopilot)
+	mux.HandleFunc("POST /v1/workspaces/{id}/config/refresh-oauth", c.handlePostWorkspaceConfigRefreshOAuth)
+	mux.HandleFunc("GET /v1/workspaces/{id}/project/needs-init", c.handleGetWorkspaceProjectNeedsInit)
+	mux.HandleFunc("POST /v1/workspaces/{id}/project/init", c.handlePostWorkspaceProjectInit)
+	mux.HandleFunc("GET /v1/workspaces/{id}/project/init-prompt", c.handleGetWorkspaceProjectInitPrompt)
+	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-tools", c.handlePostWorkspaceMCPRefreshTools)
+	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/read-resource", c.handlePostWorkspaceMCPReadResource)
+	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/get-prompt", c.handlePostWorkspaceMCPGetPrompt)
+	mux.HandleFunc("GET /v1/workspaces/{id}/mcp/states", c.handleGetWorkspaceMCPStates)
+	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-prompts", c.handlePostWorkspaceMCPRefreshPrompts)
+	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/refresh-resources", c.handlePostWorkspaceMCPRefreshResources)
+	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/docker/enable", c.handlePostWorkspaceMCPEnableDocker)
+	mux.HandleFunc("POST /v1/workspaces/{id}/mcp/docker/disable", c.handlePostWorkspaceMCPDisableDocker)
+	mux.Handle("/v1/docs/", httpswagger.WrapHandler)
+	s.h = &http.Server{
+		Protocols: &p,
+		Handler:   s.loggingHandler(mux),
+	}
+	if network == "tcp" {
+		s.h.Addr = address
+	}
+	return s
+}
+
+// Serve accepts incoming connections on the listener.
+func (s *Server) Serve(ln net.Listener) error {
+	return s.h.Serve(ln)
+}
+
+// ListenAndServe starts the server and begins accepting connections.
+func (s *Server) ListenAndServe() error {
+	if s.ln != nil {
+		return fmt.Errorf("server already started")
+	}
+	ln, err := listen(s.network, s.Addr)
+	if err != nil {
+		return fmt.Errorf("failed to listen on %s: %w", s.Addr, err)
+	}
+	return s.Serve(ln)
+}
+
+func (s *Server) closeListener() {
+	if s.ln != nil {
+		s.ln.Close()
+		s.ln = nil
+	}
+}
+
+// Close force closes all listeners and connections.
+func (s *Server) Close() error {
+	defer func() { s.closeListener() }()
+	return s.h.Close()
+}
+
+// Shutdown gracefully shuts down the server without interrupting active
+// connections.
+func (s *Server) Shutdown(ctx context.Context) error {
+	defer func() { s.closeListener() }()
+	return s.h.Shutdown(ctx)
+}
+
+func (s *Server) logDebug(r *http.Request, msg string, args ...any) {
+	if s.logger != nil {
+		s.logger.With(
+			slog.String("method", r.Method),
+			slog.String("url", r.URL.String()),
+			slog.String("remote_addr", r.RemoteAddr),
+		).Debug(msg, args...)
+	}
+}
+
+func (s *Server) logError(r *http.Request, msg string, args ...any) {
+	if s.logger != nil {
+		s.logger.With(
+			slog.String("method", r.Method),
+			slog.String("url", r.URL.String()),
+			slog.String("remote_addr", r.RemoteAddr),
+		).Error(msg, args...)
+	}
+}

internal/swagger/docs.go 🔗

@@ -0,0 +1,3589 @@
+// Package swagger Code generated by swaggo/swag. DO NOT EDIT
+package swagger
+
+import "github.com/swaggo/swag"
+
+const docTemplate = `{
+    "schemes": {{ marshal .Schemes }},
+    "swagger": "2.0",
+    "info": {
+        "description": "{{escape .Description}}",
+        "title": "{{.Title}}",
+        "contact": {
+            "name": "Charm",
+            "url": "https://charm.sh"
+        },
+        "license": {
+            "name": "MIT",
+            "url": "https://github.com/charmbracelet/crush/blob/main/LICENSE"
+        },
+        "version": "{{.Version}}"
+    },
+    "host": "{{.Host}}",
+    "basePath": "{{.BasePath}}",
+    "paths": {
+        "/config": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "system"
+                ],
+                "summary": "Get server config",
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    }
+                }
+            }
+        },
+        "/control": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "system"
+                ],
+                "summary": "Send server control command",
+                "parameters": [
+                    {
+                        "description": "Control command (e.g. shutdown)",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ServerControl"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/health": {
+            "get": {
+                "tags": [
+                    "system"
+                ],
+                "summary": "Health check",
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    }
+                }
+            }
+        },
+        "/version": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "system"
+                ],
+                "summary": "Get server version",
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.VersionInfo"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "List workspaces",
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/proto.Workspace"
+                            }
+                        }
+                    }
+                }
+            },
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Create workspace",
+                "parameters": [
+                    {
+                        "description": "Workspace creation params",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.Workspace"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Workspace"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Get workspace",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Workspace"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            },
+            "delete": {
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Delete workspace",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Get agent info",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.AgentInfo"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            },
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Send message to agent",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Agent message",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.AgentMessage"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/default-small-model": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Get default small model",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Provider ID",
+                        "name": "provider_id",
+                        "in": "query"
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/init": {
+            "post": {
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Initialize agent",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/sessions/{sid}": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Get agent session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.AgentSession"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/sessions/{sid}/cancel": {
+            "post": {
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Cancel agent session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/sessions/{sid}/prompts/clear": {
+            "post": {
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Clear prompt queue",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/sessions/{sid}/prompts/list": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "agent"
+                ],
+                "summary": "List queued prompts",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/sessions/{sid}/prompts/queued": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Get queued prompt status",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/sessions/{sid}/summarize": {
+            "post": {
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Summarize session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/update": {
+            "post": {
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Update agent",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Get workspace config",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/compact": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Set compact mode",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Config compact request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ConfigCompactRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/import-copilot": {
+            "post": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Import Copilot credentials",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.ImportCopilotResponse"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/model": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Set the preferred model",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Config model request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ConfigModelRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/provider-key": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Set provider API key",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Config provider key request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ConfigProviderKeyRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/refresh-oauth": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Refresh OAuth token",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Refresh OAuth request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ConfigRefreshOAuthRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/remove": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Remove a config field",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Config remove request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ConfigRemoveRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/set": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Set a config field",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Config set request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ConfigSetRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/events": {
+            "get": {
+                "produces": [
+                    "text/event-stream"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Stream workspace events (SSE)",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/filetracker/lastread": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "filetracker"
+                ],
+                "summary": "Get last read time for file",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "session_id",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "description": "File path",
+                        "name": "path",
+                        "in": "query",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/filetracker/read": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "filetracker"
+                ],
+                "summary": "Record file read",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "File tracker read request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.FileTrackerReadRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/lsps": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "lsp"
+                ],
+                "summary": "List LSP clients",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object",
+                            "additionalProperties": {
+                                "$ref": "#/definitions/proto.LSPClientInfo"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/lsps/start": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "lsp"
+                ],
+                "summary": "Start LSP server",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "LSP start request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.LSPStartRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/lsps/stop": {
+            "post": {
+                "tags": [
+                    "lsp"
+                ],
+                "summary": "Stop all LSP servers",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/lsps/{lsp}/diagnostics": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "lsp"
+                ],
+                "summary": "Get LSP diagnostics",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "LSP client name",
+                        "name": "lsp",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/mcp/get-prompt": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "mcp"
+                ],
+                "summary": "Get MCP prompt",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "MCP get prompt request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.MCPGetPromptRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.MCPGetPromptResponse"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/mcp/read-resource": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "mcp"
+                ],
+                "summary": "Read MCP resource",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "MCP read resource request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.MCPReadResourceRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/mcp/refresh-prompts": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "mcp"
+                ],
+                "summary": "Refresh MCP prompts",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "MCP name request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.MCPNameRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/mcp/refresh-resources": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "mcp"
+                ],
+                "summary": "Refresh MCP resources",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "MCP name request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.MCPNameRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/mcp/refresh-tools": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "mcp"
+                ],
+                "summary": "Refresh MCP tools",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "MCP name request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.MCPNameRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/mcp/states": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "mcp"
+                ],
+                "summary": "Get MCP client states",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object",
+                            "additionalProperties": {
+                                "$ref": "#/definitions/proto.MCPClientInfo"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/messages/user": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Get all user messages for workspace",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/permissions/grant": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "permissions"
+                ],
+                "summary": "Grant permission",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Permission grant",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.PermissionGrant"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/permissions/skip": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "permissions"
+                ],
+                "summary": "Get skip permissions status",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.PermissionSkipRequest"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            },
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "permissions"
+                ],
+                "summary": "Set skip permissions",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Permission skip request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.PermissionSkipRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/project/init": {
+            "post": {
+                "tags": [
+                    "project"
+                ],
+                "summary": "Mark project as initialized",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/project/init-prompt": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "project"
+                ],
+                "summary": "Get project initialization prompt",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.ProjectInitPromptResponse"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/project/needs-init": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "project"
+                ],
+                "summary": "Check if project needs initialization",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.ProjectNeedsInitResponse"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/providers": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Get workspace providers",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/sessions": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "List sessions",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/proto.Session"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            },
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Create session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Session creation params (title)",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.Session"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Session"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/sessions/{sid}": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Get session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Session"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            },
+            "put": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Update session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Updated session",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.Session"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Session"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            },
+            "delete": {
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Delete session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/sessions/{sid}/filetracker/files": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "filetracker"
+                ],
+                "summary": "List tracked files for session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/sessions/{sid}/history": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Get session history",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/proto.File"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/sessions/{sid}/messages": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Get session messages",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/sessions/{sid}/messages/user": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Get user messages for session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        }
+    },
+    "definitions": {
+        "catwalk.Model": {
+            "type": "object",
+            "properties": {
+                "can_reason": {
+                    "type": "boolean"
+                },
+                "context_window": {
+                    "type": "integer"
+                },
+                "cost_per_1m_in": {
+                    "type": "number"
+                },
+                "cost_per_1m_in_cached": {
+                    "type": "number"
+                },
+                "cost_per_1m_out": {
+                    "type": "number"
+                },
+                "cost_per_1m_out_cached": {
+                    "type": "number"
+                },
+                "default_max_tokens": {
+                    "type": "integer"
+                },
+                "default_reasoning_effort": {
+                    "type": "string"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "name": {
+                    "type": "string"
+                },
+                "options": {
+                    "$ref": "#/definitions/catwalk.ModelOptions"
+                },
+                "reasoning_levels": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "supports_attachments": {
+                    "type": "boolean"
+                }
+            }
+        },
+        "catwalk.ModelOptions": {
+            "type": "object",
+            "properties": {
+                "frequency_penalty": {
+                    "type": "number"
+                },
+                "presence_penalty": {
+                    "type": "number"
+                },
+                "provider_options": {
+                    "type": "object",
+                    "additionalProperties": {}
+                },
+                "temperature": {
+                    "type": "number"
+                },
+                "top_k": {
+                    "type": "integer"
+                },
+                "top_p": {
+                    "type": "number"
+                }
+            }
+        },
+        "config.Attribution": {
+            "type": "object",
+            "properties": {
+                "co_authored_by": {
+                    "type": "boolean"
+                },
+                "generated_with": {
+                    "type": "boolean"
+                },
+                "trailer_style": {
+                    "$ref": "#/definitions/config.TrailerStyle"
+                }
+            }
+        },
+        "config.Completions": {
+            "type": "object",
+            "properties": {
+                "max_depth": {
+                    "type": "integer"
+                },
+                "max_items": {
+                    "type": "integer"
+                }
+            }
+        },
+        "config.LSPConfig": {
+            "type": "object",
+            "properties": {
+                "args": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "command": {
+                    "type": "string"
+                },
+                "disabled": {
+                    "type": "boolean"
+                },
+                "env": {
+                    "type": "object",
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "filetypes": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "init_options": {
+                    "type": "object",
+                    "additionalProperties": {}
+                },
+                "options": {
+                    "type": "object",
+                    "additionalProperties": {}
+                },
+                "root_markers": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "timeout": {
+                    "type": "integer"
+                }
+            }
+        },
+        "config.LSPs": {
+            "type": "object",
+            "additionalProperties": {
+                "$ref": "#/definitions/config.LSPConfig"
+            }
+        },
+        "config.MCPConfig": {
+            "type": "object",
+            "properties": {
+                "args": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "command": {
+                    "type": "string"
+                },
+                "disabled": {
+                    "type": "boolean"
+                },
+                "disabled_tools": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "env": {
+                    "type": "object",
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "headers": {
+                    "description": "TODO: maybe make it possible to get the value from the env",
+                    "type": "object",
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "timeout": {
+                    "type": "integer"
+                },
+                "type": {
+                    "$ref": "#/definitions/config.MCPType"
+                },
+                "url": {
+                    "type": "string"
+                }
+            }
+        },
+        "config.MCPType": {
+            "type": "string",
+            "enum": [
+                "stdio",
+                "sse",
+                "http"
+            ],
+            "x-enum-varnames": [
+                "MCPStdio",
+                "MCPSSE",
+                "MCPHttp"
+            ]
+        },
+        "config.MCPs": {
+            "type": "object",
+            "additionalProperties": {
+                "$ref": "#/definitions/config.MCPConfig"
+            }
+        },
+        "config.Permissions": {
+            "type": "object",
+            "properties": {
+                "allowed_tools": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                }
+            }
+        },
+        "config.Scope": {
+            "type": "integer",
+            "enum": [
+                0,
+                1
+            ],
+            "x-enum-varnames": [
+                "ScopeGlobal",
+                "ScopeWorkspace"
+            ]
+        },
+        "config.SelectedModel": {
+            "type": "object",
+            "properties": {
+                "frequency_penalty": {
+                    "type": "number"
+                },
+                "max_tokens": {
+                    "description": "Overrides the default model configuration.",
+                    "type": "integer"
+                },
+                "model": {
+                    "description": "The model id as used by the provider API.\nRequired.",
+                    "type": "string"
+                },
+                "presence_penalty": {
+                    "type": "number"
+                },
+                "provider": {
+                    "description": "The model provider, same as the key/id used in the providers config.\nRequired.",
+                    "type": "string"
+                },
+                "provider_options": {
+                    "description": "Override provider specific options.",
+                    "type": "object",
+                    "additionalProperties": {}
+                },
+                "reasoning_effort": {
+                    "description": "Only used by models that use the openai provider and need this set.",
+                    "type": "string"
+                },
+                "temperature": {
+                    "type": "number"
+                },
+                "think": {
+                    "description": "Used by anthropic models that can reason to indicate if the model should think.",
+                    "type": "boolean"
+                },
+                "top_k": {
+                    "type": "integer"
+                },
+                "top_p": {
+                    "type": "number"
+                }
+            }
+        },
+        "config.SelectedModelType": {
+            "type": "string",
+            "enum": [
+                "large",
+                "small"
+            ],
+            "x-enum-varnames": [
+                "SelectedModelTypeLarge",
+                "SelectedModelTypeSmall"
+            ]
+        },
+        "config.TUIOptions": {
+            "type": "object",
+            "properties": {
+                "compact_mode": {
+                    "type": "boolean"
+                },
+                "completions": {
+                    "$ref": "#/definitions/config.Completions"
+                },
+                "diff_mode": {
+                    "type": "string"
+                },
+                "transparent": {
+                    "type": "boolean"
+                }
+            }
+        },
+        "config.ToolGrep": {
+            "type": "object",
+            "properties": {
+                "timeout": {
+                    "$ref": "#/definitions/time.Duration"
+                }
+            }
+        },
+        "config.ToolLs": {
+            "type": "object",
+            "properties": {
+                "max_depth": {
+                    "type": "integer"
+                },
+                "max_items": {
+                    "type": "integer"
+                }
+            }
+        },
+        "config.Tools": {
+            "type": "object",
+            "properties": {
+                "grep": {
+                    "$ref": "#/definitions/config.ToolGrep"
+                },
+                "ls": {
+                    "$ref": "#/definitions/config.ToolLs"
+                }
+            }
+        },
+        "config.TrailerStyle": {
+            "type": "string",
+            "enum": [
+                "none",
+                "co-authored-by",
+                "assisted-by"
+            ],
+            "x-enum-varnames": [
+                "TrailerStyleNone",
+                "TrailerStyleCoAuthoredBy",
+                "TrailerStyleAssistedBy"
+            ]
+        },
+        "csync.Map-string-config_ProviderConfig": {
+            "type": "object"
+        },
+        "github_com_charmbracelet_crush_internal_config.Config": {
+            "type": "object",
+            "properties": {
+                "$schema": {
+                    "type": "string"
+                },
+                "lsp": {
+                    "$ref": "#/definitions/config.LSPs"
+                },
+                "mcp": {
+                    "$ref": "#/definitions/config.MCPs"
+                },
+                "models": {
+                    "description": "We currently only support large/small as values here.",
+                    "type": "object",
+                    "additionalProperties": {
+                        "$ref": "#/definitions/config.SelectedModel"
+                    }
+                },
+                "options": {
+                    "$ref": "#/definitions/github_com_charmbracelet_crush_internal_config.Options"
+                },
+                "permissions": {
+                    "$ref": "#/definitions/config.Permissions"
+                },
+                "providers": {
+                    "description": "The providers that are configured",
+                    "allOf": [
+                        {
+                            "$ref": "#/definitions/csync.Map-string-config_ProviderConfig"
+                        }
+                    ]
+                },
+                "recent_models": {
+                    "description": "Recently used models stored in the data directory config.",
+                    "type": "object",
+                    "additionalProperties": {
+                        "type": "array",
+                        "items": {
+                            "$ref": "#/definitions/config.SelectedModel"
+                        }
+                    }
+                },
+                "tools": {
+                    "$ref": "#/definitions/config.Tools"
+                }
+            }
+        },
+        "github_com_charmbracelet_crush_internal_config.Options": {
+            "type": "object",
+            "properties": {
+                "attribution": {
+                    "$ref": "#/definitions/config.Attribution"
+                },
+                "auto_lsp": {
+                    "type": "boolean"
+                },
+                "context_paths": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "data_directory": {
+                    "description": "Relative to the cwd",
+                    "type": "string"
+                },
+                "debug": {
+                    "type": "boolean"
+                },
+                "debug_lsp": {
+                    "type": "boolean"
+                },
+                "disable_auto_summarize": {
+                    "type": "boolean"
+                },
+                "disable_default_providers": {
+                    "type": "boolean"
+                },
+                "disable_metrics": {
+                    "type": "boolean"
+                },
+                "disable_notifications": {
+                    "type": "boolean"
+                },
+                "disable_provider_auto_update": {
+                    "type": "boolean"
+                },
+                "disabled_tools": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "initialize_as": {
+                    "type": "string"
+                },
+                "progress": {
+                    "type": "boolean"
+                },
+                "skills_paths": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "tui": {
+                    "$ref": "#/definitions/config.TUIOptions"
+                }
+            }
+        },
+        "github_com_charmbracelet_crush_internal_proto.Message": {
+            "type": "object",
+            "properties": {
+                "created_at": {
+                    "type": "integer"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "model": {
+                    "type": "string"
+                },
+                "parts": {
+                    "type": "array",
+                    "items": {}
+                },
+                "provider": {
+                    "type": "string"
+                },
+                "role": {
+                    "$ref": "#/definitions/proto.MessageRole"
+                },
+                "session_id": {
+                    "type": "string"
+                },
+                "updated_at": {
+                    "type": "integer"
+                }
+            }
+        },
+        "lsp.ServerState": {
+            "type": "integer",
+            "enum": [
+                0,
+                1,
+                2,
+                3,
+                4,
+                5
+            ],
+            "x-enum-varnames": [
+                "StateUnstarted",
+                "StateStarting",
+                "StateReady",
+                "StateError",
+                "StateStopped",
+                "StateDisabled"
+            ]
+        },
+        "proto.AgentInfo": {
+            "type": "object",
+            "properties": {
+                "is_busy": {
+                    "type": "boolean"
+                },
+                "is_ready": {
+                    "type": "boolean"
+                },
+                "model": {
+                    "$ref": "#/definitions/catwalk.Model"
+                },
+                "model_cfg": {
+                    "$ref": "#/definitions/config.SelectedModel"
+                }
+            }
+        },
+        "proto.AgentMessage": {
+            "type": "object",
+            "properties": {
+                "attachments": {
+                    "type": "array",
+                    "items": {
+                        "$ref": "#/definitions/proto.Attachment"
+                    }
+                },
+                "prompt": {
+                    "type": "string"
+                },
+                "session_id": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.AgentSession": {
+            "type": "object",
+            "properties": {
+                "completion_tokens": {
+                    "type": "integer"
+                },
+                "cost": {
+                    "type": "number"
+                },
+                "created_at": {
+                    "type": "integer"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "is_busy": {
+                    "type": "boolean"
+                },
+                "message_count": {
+                    "type": "integer"
+                },
+                "parent_session_id": {
+                    "type": "string"
+                },
+                "prompt_tokens": {
+                    "type": "integer"
+                },
+                "summary_message_id": {
+                    "type": "string"
+                },
+                "title": {
+                    "type": "string"
+                },
+                "updated_at": {
+                    "type": "integer"
+                }
+            }
+        },
+        "proto.Attachment": {
+            "type": "object",
+            "properties": {
+                "content": {
+                    "type": "array",
+                    "items": {
+                        "type": "integer"
+                    }
+                },
+                "file_name": {
+                    "type": "string"
+                },
+                "file_path": {
+                    "type": "string"
+                },
+                "mime_type": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.ConfigCompactRequest": {
+            "type": "object",
+            "properties": {
+                "enabled": {
+                    "type": "boolean"
+                },
+                "scope": {
+                    "$ref": "#/definitions/config.Scope"
+                }
+            }
+        },
+        "proto.ConfigModelRequest": {
+            "type": "object",
+            "properties": {
+                "model": {
+                    "$ref": "#/definitions/config.SelectedModel"
+                },
+                "model_type": {
+                    "$ref": "#/definitions/config.SelectedModelType"
+                },
+                "scope": {
+                    "$ref": "#/definitions/config.Scope"
+                }
+            }
+        },
+        "proto.ConfigProviderKeyRequest": {
+            "type": "object",
+            "properties": {
+                "api_key": {},
+                "provider_id": {
+                    "type": "string"
+                },
+                "scope": {
+                    "$ref": "#/definitions/config.Scope"
+                }
+            }
+        },
+        "proto.ConfigRefreshOAuthRequest": {
+            "type": "object",
+            "properties": {
+                "provider_id": {
+                    "type": "string"
+                },
+                "scope": {
+                    "$ref": "#/definitions/config.Scope"
+                }
+            }
+        },
+        "proto.ConfigRemoveRequest": {
+            "type": "object",
+            "properties": {
+                "key": {
+                    "type": "string"
+                },
+                "scope": {
+                    "$ref": "#/definitions/config.Scope"
+                }
+            }
+        },
+        "proto.ConfigSetRequest": {
+            "type": "object",
+            "properties": {
+                "key": {
+                    "type": "string"
+                },
+                "scope": {
+                    "$ref": "#/definitions/config.Scope"
+                },
+                "value": {}
+            }
+        },
+        "proto.Error": {
+            "type": "object",
+            "properties": {
+                "message": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.File": {
+            "type": "object",
+            "properties": {
+                "content": {
+                    "type": "string"
+                },
+                "created_at": {
+                    "type": "integer"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "path": {
+                    "type": "string"
+                },
+                "session_id": {
+                    "type": "string"
+                },
+                "updated_at": {
+                    "type": "integer"
+                },
+                "version": {
+                    "type": "integer"
+                }
+            }
+        },
+        "proto.FileTrackerReadRequest": {
+            "type": "object",
+            "properties": {
+                "path": {
+                    "type": "string"
+                },
+                "session_id": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.ImportCopilotResponse": {
+            "type": "object",
+            "properties": {
+                "success": {
+                    "type": "boolean"
+                },
+                "token": {}
+            }
+        },
+        "proto.LSPClientInfo": {
+            "type": "object",
+            "properties": {
+                "connected_at": {
+                    "type": "string"
+                },
+                "diagnostic_count": {
+                    "type": "integer"
+                },
+                "error": {},
+                "name": {
+                    "type": "string"
+                },
+                "state": {
+                    "$ref": "#/definitions/lsp.ServerState"
+                }
+            }
+        },
+        "proto.LSPStartRequest": {
+            "type": "object",
+            "properties": {
+                "path": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.MCPClientInfo": {
+            "type": "object",
+            "properties": {
+                "connected_at": {
+                    "type": "string"
+                },
+                "error": {},
+                "name": {
+                    "type": "string"
+                },
+                "prompt_count": {
+                    "type": "integer"
+                },
+                "resource_count": {
+                    "type": "integer"
+                },
+                "state": {
+                    "$ref": "#/definitions/proto.MCPState"
+                },
+                "tool_count": {
+                    "type": "integer"
+                }
+            }
+        },
+        "proto.MCPGetPromptRequest": {
+            "type": "object",
+            "properties": {
+                "args": {
+                    "type": "object",
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "client_id": {
+                    "type": "string"
+                },
+                "prompt_id": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.MCPGetPromptResponse": {
+            "type": "object",
+            "properties": {
+                "prompt": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.MCPNameRequest": {
+            "type": "object",
+            "properties": {
+                "name": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.MCPReadResourceRequest": {
+            "type": "object",
+            "properties": {
+                "name": {
+                    "type": "string"
+                },
+                "uri": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.MCPState": {
+            "type": "integer",
+            "enum": [
+                0,
+                1,
+                2,
+                3
+            ],
+            "x-enum-varnames": [
+                "MCPStateDisabled",
+                "MCPStateStarting",
+                "MCPStateConnected",
+                "MCPStateError"
+            ]
+        },
+        "proto.MessageRole": {
+            "type": "string",
+            "enum": [
+                "assistant",
+                "user",
+                "system",
+                "tool"
+            ],
+            "x-enum-varnames": [
+                "Assistant",
+                "User",
+                "System",
+                "Tool"
+            ]
+        },
+        "proto.PermissionAction": {
+            "type": "string",
+            "enum": [
+                "allow",
+                "allow_session",
+                "deny"
+            ],
+            "x-enum-varnames": [
+                "PermissionAllow",
+                "PermissionAllowForSession",
+                "PermissionDeny"
+            ]
+        },
+        "proto.PermissionGrant": {
+            "type": "object",
+            "properties": {
+                "action": {
+                    "$ref": "#/definitions/proto.PermissionAction"
+                },
+                "permission": {
+                    "$ref": "#/definitions/proto.PermissionRequest"
+                }
+            }
+        },
+        "proto.PermissionRequest": {
+            "type": "object",
+            "properties": {
+                "action": {
+                    "type": "string"
+                },
+                "description": {
+                    "type": "string"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "params": {},
+                "path": {
+                    "type": "string"
+                },
+                "session_id": {
+                    "type": "string"
+                },
+                "tool_call_id": {
+                    "type": "string"
+                },
+                "tool_name": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.PermissionSkipRequest": {
+            "type": "object",
+            "properties": {
+                "skip": {
+                    "type": "boolean"
+                }
+            }
+        },
+        "proto.ProjectInitPromptResponse": {
+            "type": "object",
+            "properties": {
+                "prompt": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.ProjectNeedsInitResponse": {
+            "type": "object",
+            "properties": {
+                "needs_init": {
+                    "type": "boolean"
+                }
+            }
+        },
+        "proto.ServerControl": {
+            "type": "object",
+            "properties": {
+                "command": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.Session": {
+            "type": "object",
+            "properties": {
+                "completion_tokens": {
+                    "type": "integer"
+                },
+                "cost": {
+                    "type": "number"
+                },
+                "created_at": {
+                    "type": "integer"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "message_count": {
+                    "type": "integer"
+                },
+                "parent_session_id": {
+                    "type": "string"
+                },
+                "prompt_tokens": {
+                    "type": "integer"
+                },
+                "summary_message_id": {
+                    "type": "string"
+                },
+                "title": {
+                    "type": "string"
+                },
+                "updated_at": {
+                    "type": "integer"
+                }
+            }
+        },
+        "proto.VersionInfo": {
+            "type": "object",
+            "properties": {
+                "commit": {
+                    "type": "string"
+                },
+                "go_version": {
+                    "type": "string"
+                },
+                "platform": {
+                    "type": "string"
+                },
+                "version": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.Workspace": {
+            "type": "object",
+            "properties": {
+                "config": {
+                    "$ref": "#/definitions/github_com_charmbracelet_crush_internal_config.Config"
+                },
+                "data_dir": {
+                    "type": "string"
+                },
+                "debug": {
+                    "type": "boolean"
+                },
+                "env": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "id": {
+                    "type": "string"
+                },
+                "path": {
+                    "type": "string"
+                },
+                "version": {
+                    "type": "string"
+                },
+                "yolo": {
+                    "type": "boolean"
+                }
+            }
+        },
+        "time.Duration": {
+            "type": "integer",
+            "format": "int64",
+            "enum": [
+                -9223372036854775808,
+                9223372036854775807,
+                1,
+                1000,
+                1000000,
+                1000000000,
+                60000000000,
+                3600000000000
+            ],
+            "x-enum-varnames": [
+                "minDuration",
+                "maxDuration",
+                "Nanosecond",
+                "Microsecond",
+                "Millisecond",
+                "Second",
+                "Minute",
+                "Hour"
+            ]
+        }
+    }
+}`
+
+// SwaggerInfo holds exported Swagger Info so clients can modify it
+var SwaggerInfo = &swag.Spec{
+	Version:          "1.0",
+	Host:             "",
+	BasePath:         "/v1",
+	Schemes:          []string{},
+	Title:            "Crush API",
+	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.",
+	InfoInstanceName: "swagger",
+	SwaggerTemplate:  docTemplate,
+	LeftDelim:        "{{",
+	RightDelim:       "}}",
+}
+
+func init() {
+	swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
+}

internal/swagger/swagger.json 🔗

@@ -0,0 +1,3564 @@
+{
+    "swagger": "2.0",
+    "info": {
+        "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.",
+        "title": "Crush API",
+        "contact": {
+            "name": "Charm",
+            "url": "https://charm.sh"
+        },
+        "license": {
+            "name": "MIT",
+            "url": "https://github.com/charmbracelet/crush/blob/main/LICENSE"
+        },
+        "version": "1.0"
+    },
+    "basePath": "/v1",
+    "paths": {
+        "/config": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "system"
+                ],
+                "summary": "Get server config",
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    }
+                }
+            }
+        },
+        "/control": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "system"
+                ],
+                "summary": "Send server control command",
+                "parameters": [
+                    {
+                        "description": "Control command (e.g. shutdown)",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ServerControl"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/health": {
+            "get": {
+                "tags": [
+                    "system"
+                ],
+                "summary": "Health check",
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    }
+                }
+            }
+        },
+        "/version": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "system"
+                ],
+                "summary": "Get server version",
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.VersionInfo"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "List workspaces",
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/proto.Workspace"
+                            }
+                        }
+                    }
+                }
+            },
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Create workspace",
+                "parameters": [
+                    {
+                        "description": "Workspace creation params",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.Workspace"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Workspace"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Get workspace",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Workspace"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            },
+            "delete": {
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Delete workspace",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Get agent info",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.AgentInfo"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            },
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Send message to agent",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Agent message",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.AgentMessage"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/default-small-model": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Get default small model",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Provider ID",
+                        "name": "provider_id",
+                        "in": "query"
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/init": {
+            "post": {
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Initialize agent",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/sessions/{sid}": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Get agent session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.AgentSession"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/sessions/{sid}/cancel": {
+            "post": {
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Cancel agent session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/sessions/{sid}/prompts/clear": {
+            "post": {
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Clear prompt queue",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/sessions/{sid}/prompts/list": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "agent"
+                ],
+                "summary": "List queued prompts",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/sessions/{sid}/prompts/queued": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Get queued prompt status",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/sessions/{sid}/summarize": {
+            "post": {
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Summarize session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/agent/update": {
+            "post": {
+                "tags": [
+                    "agent"
+                ],
+                "summary": "Update agent",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Get workspace config",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/compact": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Set compact mode",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Config compact request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ConfigCompactRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/import-copilot": {
+            "post": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Import Copilot credentials",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.ImportCopilotResponse"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/model": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Set the preferred model",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Config model request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ConfigModelRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/provider-key": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Set provider API key",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Config provider key request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ConfigProviderKeyRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/refresh-oauth": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Refresh OAuth token",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Refresh OAuth request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ConfigRefreshOAuthRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/remove": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Remove a config field",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Config remove request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ConfigRemoveRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/config/set": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "config"
+                ],
+                "summary": "Set a config field",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Config set request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.ConfigSetRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/events": {
+            "get": {
+                "produces": [
+                    "text/event-stream"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Stream workspace events (SSE)",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/filetracker/lastread": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "filetracker"
+                ],
+                "summary": "Get last read time for file",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "session_id",
+                        "in": "query"
+                    },
+                    {
+                        "type": "string",
+                        "description": "File path",
+                        "name": "path",
+                        "in": "query",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/filetracker/read": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "filetracker"
+                ],
+                "summary": "Record file read",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "File tracker read request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.FileTrackerReadRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/lsps": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "lsp"
+                ],
+                "summary": "List LSP clients",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object",
+                            "additionalProperties": {
+                                "$ref": "#/definitions/proto.LSPClientInfo"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/lsps/start": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "lsp"
+                ],
+                "summary": "Start LSP server",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "LSP start request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.LSPStartRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/lsps/stop": {
+            "post": {
+                "tags": [
+                    "lsp"
+                ],
+                "summary": "Stop all LSP servers",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/lsps/{lsp}/diagnostics": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "lsp"
+                ],
+                "summary": "Get LSP diagnostics",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "LSP client name",
+                        "name": "lsp",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/mcp/get-prompt": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "mcp"
+                ],
+                "summary": "Get MCP prompt",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "MCP get prompt request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.MCPGetPromptRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.MCPGetPromptResponse"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/mcp/read-resource": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "mcp"
+                ],
+                "summary": "Read MCP resource",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "MCP read resource request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.MCPReadResourceRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/mcp/refresh-prompts": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "mcp"
+                ],
+                "summary": "Refresh MCP prompts",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "MCP name request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.MCPNameRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/mcp/refresh-resources": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "mcp"
+                ],
+                "summary": "Refresh MCP resources",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "MCP name request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.MCPNameRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/mcp/refresh-tools": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "mcp"
+                ],
+                "summary": "Refresh MCP tools",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "MCP name request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.MCPNameRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/mcp/states": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "mcp"
+                ],
+                "summary": "Get MCP client states",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object",
+                            "additionalProperties": {
+                                "$ref": "#/definitions/proto.MCPClientInfo"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/messages/user": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Get all user messages for workspace",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/permissions/grant": {
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "permissions"
+                ],
+                "summary": "Grant permission",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Permission grant",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.PermissionGrant"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/permissions/skip": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "permissions"
+                ],
+                "summary": "Get skip permissions status",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.PermissionSkipRequest"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            },
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "tags": [
+                    "permissions"
+                ],
+                "summary": "Set skip permissions",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Permission skip request",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.PermissionSkipRequest"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/project/init": {
+            "post": {
+                "tags": [
+                    "project"
+                ],
+                "summary": "Mark project as initialized",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/project/init-prompt": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "project"
+                ],
+                "summary": "Get project initialization prompt",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.ProjectInitPromptResponse"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/project/needs-init": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "project"
+                ],
+                "summary": "Check if project needs initialization",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.ProjectNeedsInitResponse"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/providers": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "workspaces"
+                ],
+                "summary": "Get workspace providers",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "object"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/sessions": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "List sessions",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/proto.Session"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            },
+            "post": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Create session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Session creation params (title)",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.Session"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Session"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/sessions/{sid}": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Get session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Session"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            },
+            "put": {
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Update session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "description": "Updated session",
+                        "name": "request",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "$ref": "#/definitions/proto.Session"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Session"
+                        }
+                    },
+                    "400": {
+                        "description": "Bad Request",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            },
+            "delete": {
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Delete session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK"
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/sessions/{sid}/filetracker/files": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "filetracker"
+                ],
+                "summary": "List tracked files for session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "type": "string"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/sessions/{sid}/history": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Get session history",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/proto.File"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/sessions/{sid}/messages": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Get session messages",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        },
+        "/workspaces/{id}/sessions/{sid}/messages/user": {
+            "get": {
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "sessions"
+                ],
+                "summary": "Get user messages for session",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "description": "Workspace ID",
+                        "name": "id",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "description": "Session ID",
+                        "name": "sid",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "type": "array",
+                            "items": {
+                                "$ref": "#/definitions/github_com_charmbracelet_crush_internal_proto.Message"
+                            }
+                        }
+                    },
+                    "404": {
+                        "description": "Not Found",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/proto.Error"
+                        }
+                    }
+                }
+            }
+        }
+    },
+    "definitions": {
+        "catwalk.Model": {
+            "type": "object",
+            "properties": {
+                "can_reason": {
+                    "type": "boolean"
+                },
+                "context_window": {
+                    "type": "integer"
+                },
+                "cost_per_1m_in": {
+                    "type": "number"
+                },
+                "cost_per_1m_in_cached": {
+                    "type": "number"
+                },
+                "cost_per_1m_out": {
+                    "type": "number"
+                },
+                "cost_per_1m_out_cached": {
+                    "type": "number"
+                },
+                "default_max_tokens": {
+                    "type": "integer"
+                },
+                "default_reasoning_effort": {
+                    "type": "string"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "name": {
+                    "type": "string"
+                },
+                "options": {
+                    "$ref": "#/definitions/catwalk.ModelOptions"
+                },
+                "reasoning_levels": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "supports_attachments": {
+                    "type": "boolean"
+                }
+            }
+        },
+        "catwalk.ModelOptions": {
+            "type": "object",
+            "properties": {
+                "frequency_penalty": {
+                    "type": "number"
+                },
+                "presence_penalty": {
+                    "type": "number"
+                },
+                "provider_options": {
+                    "type": "object",
+                    "additionalProperties": {}
+                },
+                "temperature": {
+                    "type": "number"
+                },
+                "top_k": {
+                    "type": "integer"
+                },
+                "top_p": {
+                    "type": "number"
+                }
+            }
+        },
+        "config.Attribution": {
+            "type": "object",
+            "properties": {
+                "co_authored_by": {
+                    "type": "boolean"
+                },
+                "generated_with": {
+                    "type": "boolean"
+                },
+                "trailer_style": {
+                    "$ref": "#/definitions/config.TrailerStyle"
+                }
+            }
+        },
+        "config.Completions": {
+            "type": "object",
+            "properties": {
+                "max_depth": {
+                    "type": "integer"
+                },
+                "max_items": {
+                    "type": "integer"
+                }
+            }
+        },
+        "config.LSPConfig": {
+            "type": "object",
+            "properties": {
+                "args": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "command": {
+                    "type": "string"
+                },
+                "disabled": {
+                    "type": "boolean"
+                },
+                "env": {
+                    "type": "object",
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "filetypes": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "init_options": {
+                    "type": "object",
+                    "additionalProperties": {}
+                },
+                "options": {
+                    "type": "object",
+                    "additionalProperties": {}
+                },
+                "root_markers": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "timeout": {
+                    "type": "integer"
+                }
+            }
+        },
+        "config.LSPs": {
+            "type": "object",
+            "additionalProperties": {
+                "$ref": "#/definitions/config.LSPConfig"
+            }
+        },
+        "config.MCPConfig": {
+            "type": "object",
+            "properties": {
+                "args": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "command": {
+                    "type": "string"
+                },
+                "disabled": {
+                    "type": "boolean"
+                },
+                "disabled_tools": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "env": {
+                    "type": "object",
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "headers": {
+                    "description": "TODO: maybe make it possible to get the value from the env",
+                    "type": "object",
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "timeout": {
+                    "type": "integer"
+                },
+                "type": {
+                    "$ref": "#/definitions/config.MCPType"
+                },
+                "url": {
+                    "type": "string"
+                }
+            }
+        },
+        "config.MCPType": {
+            "type": "string",
+            "enum": [
+                "stdio",
+                "sse",
+                "http"
+            ],
+            "x-enum-varnames": [
+                "MCPStdio",
+                "MCPSSE",
+                "MCPHttp"
+            ]
+        },
+        "config.MCPs": {
+            "type": "object",
+            "additionalProperties": {
+                "$ref": "#/definitions/config.MCPConfig"
+            }
+        },
+        "config.Permissions": {
+            "type": "object",
+            "properties": {
+                "allowed_tools": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                }
+            }
+        },
+        "config.Scope": {
+            "type": "integer",
+            "enum": [
+                0,
+                1
+            ],
+            "x-enum-varnames": [
+                "ScopeGlobal",
+                "ScopeWorkspace"
+            ]
+        },
+        "config.SelectedModel": {
+            "type": "object",
+            "properties": {
+                "frequency_penalty": {
+                    "type": "number"
+                },
+                "max_tokens": {
+                    "description": "Overrides the default model configuration.",
+                    "type": "integer"
+                },
+                "model": {
+                    "description": "The model id as used by the provider API.\nRequired.",
+                    "type": "string"
+                },
+                "presence_penalty": {
+                    "type": "number"
+                },
+                "provider": {
+                    "description": "The model provider, same as the key/id used in the providers config.\nRequired.",
+                    "type": "string"
+                },
+                "provider_options": {
+                    "description": "Override provider specific options.",
+                    "type": "object",
+                    "additionalProperties": {}
+                },
+                "reasoning_effort": {
+                    "description": "Only used by models that use the openai provider and need this set.",
+                    "type": "string"
+                },
+                "temperature": {
+                    "type": "number"
+                },
+                "think": {
+                    "description": "Used by anthropic models that can reason to indicate if the model should think.",
+                    "type": "boolean"
+                },
+                "top_k": {
+                    "type": "integer"
+                },
+                "top_p": {
+                    "type": "number"
+                }
+            }
+        },
+        "config.SelectedModelType": {
+            "type": "string",
+            "enum": [
+                "large",
+                "small"
+            ],
+            "x-enum-varnames": [
+                "SelectedModelTypeLarge",
+                "SelectedModelTypeSmall"
+            ]
+        },
+        "config.TUIOptions": {
+            "type": "object",
+            "properties": {
+                "compact_mode": {
+                    "type": "boolean"
+                },
+                "completions": {
+                    "$ref": "#/definitions/config.Completions"
+                },
+                "diff_mode": {
+                    "type": "string"
+                },
+                "transparent": {
+                    "type": "boolean"
+                }
+            }
+        },
+        "config.ToolGrep": {
+            "type": "object",
+            "properties": {
+                "timeout": {
+                    "$ref": "#/definitions/time.Duration"
+                }
+            }
+        },
+        "config.ToolLs": {
+            "type": "object",
+            "properties": {
+                "max_depth": {
+                    "type": "integer"
+                },
+                "max_items": {
+                    "type": "integer"
+                }
+            }
+        },
+        "config.Tools": {
+            "type": "object",
+            "properties": {
+                "grep": {
+                    "$ref": "#/definitions/config.ToolGrep"
+                },
+                "ls": {
+                    "$ref": "#/definitions/config.ToolLs"
+                }
+            }
+        },
+        "config.TrailerStyle": {
+            "type": "string",
+            "enum": [
+                "none",
+                "co-authored-by",
+                "assisted-by"
+            ],
+            "x-enum-varnames": [
+                "TrailerStyleNone",
+                "TrailerStyleCoAuthoredBy",
+                "TrailerStyleAssistedBy"
+            ]
+        },
+        "csync.Map-string-config_ProviderConfig": {
+            "type": "object"
+        },
+        "github_com_charmbracelet_crush_internal_config.Config": {
+            "type": "object",
+            "properties": {
+                "$schema": {
+                    "type": "string"
+                },
+                "lsp": {
+                    "$ref": "#/definitions/config.LSPs"
+                },
+                "mcp": {
+                    "$ref": "#/definitions/config.MCPs"
+                },
+                "models": {
+                    "description": "We currently only support large/small as values here.",
+                    "type": "object",
+                    "additionalProperties": {
+                        "$ref": "#/definitions/config.SelectedModel"
+                    }
+                },
+                "options": {
+                    "$ref": "#/definitions/github_com_charmbracelet_crush_internal_config.Options"
+                },
+                "permissions": {
+                    "$ref": "#/definitions/config.Permissions"
+                },
+                "providers": {
+                    "description": "The providers that are configured",
+                    "allOf": [
+                        {
+                            "$ref": "#/definitions/csync.Map-string-config_ProviderConfig"
+                        }
+                    ]
+                },
+                "recent_models": {
+                    "description": "Recently used models stored in the data directory config.",
+                    "type": "object",
+                    "additionalProperties": {
+                        "type": "array",
+                        "items": {
+                            "$ref": "#/definitions/config.SelectedModel"
+                        }
+                    }
+                },
+                "tools": {
+                    "$ref": "#/definitions/config.Tools"
+                }
+            }
+        },
+        "github_com_charmbracelet_crush_internal_config.Options": {
+            "type": "object",
+            "properties": {
+                "attribution": {
+                    "$ref": "#/definitions/config.Attribution"
+                },
+                "auto_lsp": {
+                    "type": "boolean"
+                },
+                "context_paths": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "data_directory": {
+                    "description": "Relative to the cwd",
+                    "type": "string"
+                },
+                "debug": {
+                    "type": "boolean"
+                },
+                "debug_lsp": {
+                    "type": "boolean"
+                },
+                "disable_auto_summarize": {
+                    "type": "boolean"
+                },
+                "disable_default_providers": {
+                    "type": "boolean"
+                },
+                "disable_metrics": {
+                    "type": "boolean"
+                },
+                "disable_notifications": {
+                    "type": "boolean"
+                },
+                "disable_provider_auto_update": {
+                    "type": "boolean"
+                },
+                "disabled_tools": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "initialize_as": {
+                    "type": "string"
+                },
+                "progress": {
+                    "type": "boolean"
+                },
+                "skills_paths": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "tui": {
+                    "$ref": "#/definitions/config.TUIOptions"
+                }
+            }
+        },
+        "github_com_charmbracelet_crush_internal_proto.Message": {
+            "type": "object",
+            "properties": {
+                "created_at": {
+                    "type": "integer"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "model": {
+                    "type": "string"
+                },
+                "parts": {
+                    "type": "array",
+                    "items": {}
+                },
+                "provider": {
+                    "type": "string"
+                },
+                "role": {
+                    "$ref": "#/definitions/proto.MessageRole"
+                },
+                "session_id": {
+                    "type": "string"
+                },
+                "updated_at": {
+                    "type": "integer"
+                }
+            }
+        },
+        "lsp.ServerState": {
+            "type": "integer",
+            "enum": [
+                0,
+                1,
+                2,
+                3,
+                4,
+                5
+            ],
+            "x-enum-varnames": [
+                "StateUnstarted",
+                "StateStarting",
+                "StateReady",
+                "StateError",
+                "StateStopped",
+                "StateDisabled"
+            ]
+        },
+        "proto.AgentInfo": {
+            "type": "object",
+            "properties": {
+                "is_busy": {
+                    "type": "boolean"
+                },
+                "is_ready": {
+                    "type": "boolean"
+                },
+                "model": {
+                    "$ref": "#/definitions/catwalk.Model"
+                },
+                "model_cfg": {
+                    "$ref": "#/definitions/config.SelectedModel"
+                }
+            }
+        },
+        "proto.AgentMessage": {
+            "type": "object",
+            "properties": {
+                "attachments": {
+                    "type": "array",
+                    "items": {
+                        "$ref": "#/definitions/proto.Attachment"
+                    }
+                },
+                "prompt": {
+                    "type": "string"
+                },
+                "session_id": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.AgentSession": {
+            "type": "object",
+            "properties": {
+                "completion_tokens": {
+                    "type": "integer"
+                },
+                "cost": {
+                    "type": "number"
+                },
+                "created_at": {
+                    "type": "integer"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "is_busy": {
+                    "type": "boolean"
+                },
+                "message_count": {
+                    "type": "integer"
+                },
+                "parent_session_id": {
+                    "type": "string"
+                },
+                "prompt_tokens": {
+                    "type": "integer"
+                },
+                "summary_message_id": {
+                    "type": "string"
+                },
+                "title": {
+                    "type": "string"
+                },
+                "updated_at": {
+                    "type": "integer"
+                }
+            }
+        },
+        "proto.Attachment": {
+            "type": "object",
+            "properties": {
+                "content": {
+                    "type": "array",
+                    "items": {
+                        "type": "integer"
+                    }
+                },
+                "file_name": {
+                    "type": "string"
+                },
+                "file_path": {
+                    "type": "string"
+                },
+                "mime_type": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.ConfigCompactRequest": {
+            "type": "object",
+            "properties": {
+                "enabled": {
+                    "type": "boolean"
+                },
+                "scope": {
+                    "$ref": "#/definitions/config.Scope"
+                }
+            }
+        },
+        "proto.ConfigModelRequest": {
+            "type": "object",
+            "properties": {
+                "model": {
+                    "$ref": "#/definitions/config.SelectedModel"
+                },
+                "model_type": {
+                    "$ref": "#/definitions/config.SelectedModelType"
+                },
+                "scope": {
+                    "$ref": "#/definitions/config.Scope"
+                }
+            }
+        },
+        "proto.ConfigProviderKeyRequest": {
+            "type": "object",
+            "properties": {
+                "api_key": {},
+                "provider_id": {
+                    "type": "string"
+                },
+                "scope": {
+                    "$ref": "#/definitions/config.Scope"
+                }
+            }
+        },
+        "proto.ConfigRefreshOAuthRequest": {
+            "type": "object",
+            "properties": {
+                "provider_id": {
+                    "type": "string"
+                },
+                "scope": {
+                    "$ref": "#/definitions/config.Scope"
+                }
+            }
+        },
+        "proto.ConfigRemoveRequest": {
+            "type": "object",
+            "properties": {
+                "key": {
+                    "type": "string"
+                },
+                "scope": {
+                    "$ref": "#/definitions/config.Scope"
+                }
+            }
+        },
+        "proto.ConfigSetRequest": {
+            "type": "object",
+            "properties": {
+                "key": {
+                    "type": "string"
+                },
+                "scope": {
+                    "$ref": "#/definitions/config.Scope"
+                },
+                "value": {}
+            }
+        },
+        "proto.Error": {
+            "type": "object",
+            "properties": {
+                "message": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.File": {
+            "type": "object",
+            "properties": {
+                "content": {
+                    "type": "string"
+                },
+                "created_at": {
+                    "type": "integer"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "path": {
+                    "type": "string"
+                },
+                "session_id": {
+                    "type": "string"
+                },
+                "updated_at": {
+                    "type": "integer"
+                },
+                "version": {
+                    "type": "integer"
+                }
+            }
+        },
+        "proto.FileTrackerReadRequest": {
+            "type": "object",
+            "properties": {
+                "path": {
+                    "type": "string"
+                },
+                "session_id": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.ImportCopilotResponse": {
+            "type": "object",
+            "properties": {
+                "success": {
+                    "type": "boolean"
+                },
+                "token": {}
+            }
+        },
+        "proto.LSPClientInfo": {
+            "type": "object",
+            "properties": {
+                "connected_at": {
+                    "type": "string"
+                },
+                "diagnostic_count": {
+                    "type": "integer"
+                },
+                "error": {},
+                "name": {
+                    "type": "string"
+                },
+                "state": {
+                    "$ref": "#/definitions/lsp.ServerState"
+                }
+            }
+        },
+        "proto.LSPStartRequest": {
+            "type": "object",
+            "properties": {
+                "path": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.MCPClientInfo": {
+            "type": "object",
+            "properties": {
+                "connected_at": {
+                    "type": "string"
+                },
+                "error": {},
+                "name": {
+                    "type": "string"
+                },
+                "prompt_count": {
+                    "type": "integer"
+                },
+                "resource_count": {
+                    "type": "integer"
+                },
+                "state": {
+                    "$ref": "#/definitions/proto.MCPState"
+                },
+                "tool_count": {
+                    "type": "integer"
+                }
+            }
+        },
+        "proto.MCPGetPromptRequest": {
+            "type": "object",
+            "properties": {
+                "args": {
+                    "type": "object",
+                    "additionalProperties": {
+                        "type": "string"
+                    }
+                },
+                "client_id": {
+                    "type": "string"
+                },
+                "prompt_id": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.MCPGetPromptResponse": {
+            "type": "object",
+            "properties": {
+                "prompt": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.MCPNameRequest": {
+            "type": "object",
+            "properties": {
+                "name": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.MCPReadResourceRequest": {
+            "type": "object",
+            "properties": {
+                "name": {
+                    "type": "string"
+                },
+                "uri": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.MCPState": {
+            "type": "integer",
+            "enum": [
+                0,
+                1,
+                2,
+                3
+            ],
+            "x-enum-varnames": [
+                "MCPStateDisabled",
+                "MCPStateStarting",
+                "MCPStateConnected",
+                "MCPStateError"
+            ]
+        },
+        "proto.MessageRole": {
+            "type": "string",
+            "enum": [
+                "assistant",
+                "user",
+                "system",
+                "tool"
+            ],
+            "x-enum-varnames": [
+                "Assistant",
+                "User",
+                "System",
+                "Tool"
+            ]
+        },
+        "proto.PermissionAction": {
+            "type": "string",
+            "enum": [
+                "allow",
+                "allow_session",
+                "deny"
+            ],
+            "x-enum-varnames": [
+                "PermissionAllow",
+                "PermissionAllowForSession",
+                "PermissionDeny"
+            ]
+        },
+        "proto.PermissionGrant": {
+            "type": "object",
+            "properties": {
+                "action": {
+                    "$ref": "#/definitions/proto.PermissionAction"
+                },
+                "permission": {
+                    "$ref": "#/definitions/proto.PermissionRequest"
+                }
+            }
+        },
+        "proto.PermissionRequest": {
+            "type": "object",
+            "properties": {
+                "action": {
+                    "type": "string"
+                },
+                "description": {
+                    "type": "string"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "params": {},
+                "path": {
+                    "type": "string"
+                },
+                "session_id": {
+                    "type": "string"
+                },
+                "tool_call_id": {
+                    "type": "string"
+                },
+                "tool_name": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.PermissionSkipRequest": {
+            "type": "object",
+            "properties": {
+                "skip": {
+                    "type": "boolean"
+                }
+            }
+        },
+        "proto.ProjectInitPromptResponse": {
+            "type": "object",
+            "properties": {
+                "prompt": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.ProjectNeedsInitResponse": {
+            "type": "object",
+            "properties": {
+                "needs_init": {
+                    "type": "boolean"
+                }
+            }
+        },
+        "proto.ServerControl": {
+            "type": "object",
+            "properties": {
+                "command": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.Session": {
+            "type": "object",
+            "properties": {
+                "completion_tokens": {
+                    "type": "integer"
+                },
+                "cost": {
+                    "type": "number"
+                },
+                "created_at": {
+                    "type": "integer"
+                },
+                "id": {
+                    "type": "string"
+                },
+                "message_count": {
+                    "type": "integer"
+                },
+                "parent_session_id": {
+                    "type": "string"
+                },
+                "prompt_tokens": {
+                    "type": "integer"
+                },
+                "summary_message_id": {
+                    "type": "string"
+                },
+                "title": {
+                    "type": "string"
+                },
+                "updated_at": {
+                    "type": "integer"
+                }
+            }
+        },
+        "proto.VersionInfo": {
+            "type": "object",
+            "properties": {
+                "commit": {
+                    "type": "string"
+                },
+                "go_version": {
+                    "type": "string"
+                },
+                "platform": {
+                    "type": "string"
+                },
+                "version": {
+                    "type": "string"
+                }
+            }
+        },
+        "proto.Workspace": {
+            "type": "object",
+            "properties": {
+                "config": {
+                    "$ref": "#/definitions/github_com_charmbracelet_crush_internal_config.Config"
+                },
+                "data_dir": {
+                    "type": "string"
+                },
+                "debug": {
+                    "type": "boolean"
+                },
+                "env": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                },
+                "id": {
+                    "type": "string"
+                },
+                "path": {
+                    "type": "string"
+                },
+                "version": {
+                    "type": "string"
+                },
+                "yolo": {
+                    "type": "boolean"
+                }
+            }
+        },
+        "time.Duration": {
+            "type": "integer",
+            "format": "int64",
+            "enum": [
+                -9223372036854775808,
+                9223372036854775807,
+                1,
+                1000,
+                1000000,
+                1000000000,
+                60000000000,
+                3600000000000
+            ],
+            "x-enum-varnames": [
+                "minDuration",
+                "maxDuration",
+                "Nanosecond",
+                "Microsecond",
+                "Millisecond",
+                "Second",
+                "Minute",
+                "Hour"
+            ]
+        }
+    }
+}

internal/swagger/swagger.yaml 🔗

@@ -0,0 +1,2361 @@
+basePath: /v1
+definitions:
+  catwalk.Model:
+    properties:
+      can_reason:
+        type: boolean
+      context_window:
+        type: integer
+      cost_per_1m_in:
+        type: number
+      cost_per_1m_in_cached:
+        type: number
+      cost_per_1m_out:
+        type: number
+      cost_per_1m_out_cached:
+        type: number
+      default_max_tokens:
+        type: integer
+      default_reasoning_effort:
+        type: string
+      id:
+        type: string
+      name:
+        type: string
+      options:
+        $ref: '#/definitions/catwalk.ModelOptions'
+      reasoning_levels:
+        items:
+          type: string
+        type: array
+      supports_attachments:
+        type: boolean
+    type: object
+  catwalk.ModelOptions:
+    properties:
+      frequency_penalty:
+        type: number
+      presence_penalty:
+        type: number
+      provider_options:
+        additionalProperties: {}
+        type: object
+      temperature:
+        type: number
+      top_k:
+        type: integer
+      top_p:
+        type: number
+    type: object
+  config.Attribution:
+    properties:
+      co_authored_by:
+        type: boolean
+      generated_with:
+        type: boolean
+      trailer_style:
+        $ref: '#/definitions/config.TrailerStyle'
+    type: object
+  config.Completions:
+    properties:
+      max_depth:
+        type: integer
+      max_items:
+        type: integer
+    type: object
+  config.LSPConfig:
+    properties:
+      args:
+        items:
+          type: string
+        type: array
+      command:
+        type: string
+      disabled:
+        type: boolean
+      env:
+        additionalProperties:
+          type: string
+        type: object
+      filetypes:
+        items:
+          type: string
+        type: array
+      init_options:
+        additionalProperties: {}
+        type: object
+      options:
+        additionalProperties: {}
+        type: object
+      root_markers:
+        items:
+          type: string
+        type: array
+      timeout:
+        type: integer
+    type: object
+  config.LSPs:
+    additionalProperties:
+      $ref: '#/definitions/config.LSPConfig'
+    type: object
+  config.MCPConfig:
+    properties:
+      args:
+        items:
+          type: string
+        type: array
+      command:
+        type: string
+      disabled:
+        type: boolean
+      disabled_tools:
+        items:
+          type: string
+        type: array
+      env:
+        additionalProperties:
+          type: string
+        type: object
+      headers:
+        additionalProperties:
+          type: string
+        description: 'TODO: maybe make it possible to get the value from the env'
+        type: object
+      timeout:
+        type: integer
+      type:
+        $ref: '#/definitions/config.MCPType'
+      url:
+        type: string
+    type: object
+  config.MCPType:
+    enum:
+    - stdio
+    - sse
+    - http
+    type: string
+    x-enum-varnames:
+    - MCPStdio
+    - MCPSSE
+    - MCPHttp
+  config.MCPs:
+    additionalProperties:
+      $ref: '#/definitions/config.MCPConfig'
+    type: object
+  config.Permissions:
+    properties:
+      allowed_tools:
+        items:
+          type: string
+        type: array
+    type: object
+  config.Scope:
+    enum:
+    - 0
+    - 1
+    type: integer
+    x-enum-varnames:
+    - ScopeGlobal
+    - ScopeWorkspace
+  config.SelectedModel:
+    properties:
+      frequency_penalty:
+        type: number
+      max_tokens:
+        description: Overrides the default model configuration.
+        type: integer
+      model:
+        description: |-
+          The model id as used by the provider API.
+          Required.
+        type: string
+      presence_penalty:
+        type: number
+      provider:
+        description: |-
+          The model provider, same as the key/id used in the providers config.
+          Required.
+        type: string
+      provider_options:
+        additionalProperties: {}
+        description: Override provider specific options.
+        type: object
+      reasoning_effort:
+        description: Only used by models that use the openai provider and need this
+          set.
+        type: string
+      temperature:
+        type: number
+      think:
+        description: Used by anthropic models that can reason to indicate if the model
+          should think.
+        type: boolean
+      top_k:
+        type: integer
+      top_p:
+        type: number
+    type: object
+  config.SelectedModelType:
+    enum:
+    - large
+    - small
+    type: string
+    x-enum-varnames:
+    - SelectedModelTypeLarge
+    - SelectedModelTypeSmall
+  config.TUIOptions:
+    properties:
+      compact_mode:
+        type: boolean
+      completions:
+        $ref: '#/definitions/config.Completions'
+      diff_mode:
+        type: string
+      transparent:
+        type: boolean
+    type: object
+  config.ToolGrep:
+    properties:
+      timeout:
+        $ref: '#/definitions/time.Duration'
+    type: object
+  config.ToolLs:
+    properties:
+      max_depth:
+        type: integer
+      max_items:
+        type: integer
+    type: object
+  config.Tools:
+    properties:
+      grep:
+        $ref: '#/definitions/config.ToolGrep'
+      ls:
+        $ref: '#/definitions/config.ToolLs'
+    type: object
+  config.TrailerStyle:
+    enum:
+    - none
+    - co-authored-by
+    - assisted-by
+    type: string
+    x-enum-varnames:
+    - TrailerStyleNone
+    - TrailerStyleCoAuthoredBy
+    - TrailerStyleAssistedBy
+  csync.Map-string-config_ProviderConfig:
+    type: object
+  github_com_charmbracelet_crush_internal_config.Config:
+    properties:
+      $schema:
+        type: string
+      lsp:
+        $ref: '#/definitions/config.LSPs'
+      mcp:
+        $ref: '#/definitions/config.MCPs'
+      models:
+        additionalProperties:
+          $ref: '#/definitions/config.SelectedModel'
+        description: We currently only support large/small as values here.
+        type: object
+      options:
+        $ref: '#/definitions/github_com_charmbracelet_crush_internal_config.Options'
+      permissions:
+        $ref: '#/definitions/config.Permissions'
+      providers:
+        allOf:
+        - $ref: '#/definitions/csync.Map-string-config_ProviderConfig'
+        description: The providers that are configured
+      recent_models:
+        additionalProperties:
+          items:
+            $ref: '#/definitions/config.SelectedModel'
+          type: array
+        description: Recently used models stored in the data directory config.
+        type: object
+      tools:
+        $ref: '#/definitions/config.Tools'
+    type: object
+  github_com_charmbracelet_crush_internal_config.Options:
+    properties:
+      attribution:
+        $ref: '#/definitions/config.Attribution'
+      auto_lsp:
+        type: boolean
+      context_paths:
+        items:
+          type: string
+        type: array
+      data_directory:
+        description: Relative to the cwd
+        type: string
+      debug:
+        type: boolean
+      debug_lsp:
+        type: boolean
+      disable_auto_summarize:
+        type: boolean
+      disable_default_providers:
+        type: boolean
+      disable_metrics:
+        type: boolean
+      disable_notifications:
+        type: boolean
+      disable_provider_auto_update:
+        type: boolean
+      disabled_tools:
+        items:
+          type: string
+        type: array
+      initialize_as:
+        type: string
+      progress:
+        type: boolean
+      skills_paths:
+        items:
+          type: string
+        type: array
+      tui:
+        $ref: '#/definitions/config.TUIOptions'
+    type: object
+  github_com_charmbracelet_crush_internal_proto.Message:
+    properties:
+      created_at:
+        type: integer
+      id:
+        type: string
+      model:
+        type: string
+      parts:
+        items: {}
+        type: array
+      provider:
+        type: string
+      role:
+        $ref: '#/definitions/proto.MessageRole'
+      session_id:
+        type: string
+      updated_at:
+        type: integer
+    type: object
+  lsp.ServerState:
+    enum:
+    - 0
+    - 1
+    - 2
+    - 3
+    - 4
+    - 5
+    type: integer
+    x-enum-varnames:
+    - StateUnstarted
+    - StateStarting
+    - StateReady
+    - StateError
+    - StateStopped
+    - StateDisabled
+  proto.AgentInfo:
+    properties:
+      is_busy:
+        type: boolean
+      is_ready:
+        type: boolean
+      model:
+        $ref: '#/definitions/catwalk.Model'
+      model_cfg:
+        $ref: '#/definitions/config.SelectedModel'
+    type: object
+  proto.AgentMessage:
+    properties:
+      attachments:
+        items:
+          $ref: '#/definitions/proto.Attachment'
+        type: array
+      prompt:
+        type: string
+      session_id:
+        type: string
+    type: object
+  proto.AgentSession:
+    properties:
+      completion_tokens:
+        type: integer
+      cost:
+        type: number
+      created_at:
+        type: integer
+      id:
+        type: string
+      is_busy:
+        type: boolean
+      message_count:
+        type: integer
+      parent_session_id:
+        type: string
+      prompt_tokens:
+        type: integer
+      summary_message_id:
+        type: string
+      title:
+        type: string
+      updated_at:
+        type: integer
+    type: object
+  proto.Attachment:
+    properties:
+      content:
+        items:
+          type: integer
+        type: array
+      file_name:
+        type: string
+      file_path:
+        type: string
+      mime_type:
+        type: string
+    type: object
+  proto.ConfigCompactRequest:
+    properties:
+      enabled:
+        type: boolean
+      scope:
+        $ref: '#/definitions/config.Scope'
+    type: object
+  proto.ConfigModelRequest:
+    properties:
+      model:
+        $ref: '#/definitions/config.SelectedModel'
+      model_type:
+        $ref: '#/definitions/config.SelectedModelType'
+      scope:
+        $ref: '#/definitions/config.Scope'
+    type: object
+  proto.ConfigProviderKeyRequest:
+    properties:
+      api_key: {}
+      provider_id:
+        type: string
+      scope:
+        $ref: '#/definitions/config.Scope'
+    type: object
+  proto.ConfigRefreshOAuthRequest:
+    properties:
+      provider_id:
+        type: string
+      scope:
+        $ref: '#/definitions/config.Scope'
+    type: object
+  proto.ConfigRemoveRequest:
+    properties:
+      key:
+        type: string
+      scope:
+        $ref: '#/definitions/config.Scope'
+    type: object
+  proto.ConfigSetRequest:
+    properties:
+      key:
+        type: string
+      scope:
+        $ref: '#/definitions/config.Scope'
+      value: {}
+    type: object
+  proto.Error:
+    properties:
+      message:
+        type: string
+    type: object
+  proto.File:
+    properties:
+      content:
+        type: string
+      created_at:
+        type: integer
+      id:
+        type: string
+      path:
+        type: string
+      session_id:
+        type: string
+      updated_at:
+        type: integer
+      version:
+        type: integer
+    type: object
+  proto.FileTrackerReadRequest:
+    properties:
+      path:
+        type: string
+      session_id:
+        type: string
+    type: object
+  proto.ImportCopilotResponse:
+    properties:
+      success:
+        type: boolean
+      token: {}
+    type: object
+  proto.LSPClientInfo:
+    properties:
+      connected_at:
+        type: string
+      diagnostic_count:
+        type: integer
+      error: {}
+      name:
+        type: string
+      state:
+        $ref: '#/definitions/lsp.ServerState'
+    type: object
+  proto.LSPStartRequest:
+    properties:
+      path:
+        type: string
+    type: object
+  proto.MCPClientInfo:
+    properties:
+      connected_at:
+        type: string
+      error: {}
+      name:
+        type: string
+      prompt_count:
+        type: integer
+      resource_count:
+        type: integer
+      state:
+        $ref: '#/definitions/proto.MCPState'
+      tool_count:
+        type: integer
+    type: object
+  proto.MCPGetPromptRequest:
+    properties:
+      args:
+        additionalProperties:
+          type: string
+        type: object
+      client_id:
+        type: string
+      prompt_id:
+        type: string
+    type: object
+  proto.MCPGetPromptResponse:
+    properties:
+      prompt:
+        type: string
+    type: object
+  proto.MCPNameRequest:
+    properties:
+      name:
+        type: string
+    type: object
+  proto.MCPReadResourceRequest:
+    properties:
+      name:
+        type: string
+      uri:
+        type: string
+    type: object
+  proto.MCPState:
+    enum:
+    - 0
+    - 1
+    - 2
+    - 3
+    type: integer
+    x-enum-varnames:
+    - MCPStateDisabled
+    - MCPStateStarting
+    - MCPStateConnected
+    - MCPStateError
+  proto.MessageRole:
+    enum:
+    - assistant
+    - user
+    - system
+    - tool
+    type: string
+    x-enum-varnames:
+    - Assistant
+    - User
+    - System
+    - Tool
+  proto.PermissionAction:
+    enum:
+    - allow
+    - allow_session
+    - deny
+    type: string
+    x-enum-varnames:
+    - PermissionAllow
+    - PermissionAllowForSession
+    - PermissionDeny
+  proto.PermissionGrant:
+    properties:
+      action:
+        $ref: '#/definitions/proto.PermissionAction'
+      permission:
+        $ref: '#/definitions/proto.PermissionRequest'
+    type: object
+  proto.PermissionRequest:
+    properties:
+      action:
+        type: string
+      description:
+        type: string
+      id:
+        type: string
+      params: {}
+      path:
+        type: string
+      session_id:
+        type: string
+      tool_call_id:
+        type: string
+      tool_name:
+        type: string
+    type: object
+  proto.PermissionSkipRequest:
+    properties:
+      skip:
+        type: boolean
+    type: object
+  proto.ProjectInitPromptResponse:
+    properties:
+      prompt:
+        type: string
+    type: object
+  proto.ProjectNeedsInitResponse:
+    properties:
+      needs_init:
+        type: boolean
+    type: object
+  proto.ServerControl:
+    properties:
+      command:
+        type: string
+    type: object
+  proto.Session:
+    properties:
+      completion_tokens:
+        type: integer
+      cost:
+        type: number
+      created_at:
+        type: integer
+      id:
+        type: string
+      message_count:
+        type: integer
+      parent_session_id:
+        type: string
+      prompt_tokens:
+        type: integer
+      summary_message_id:
+        type: string
+      title:
+        type: string
+      updated_at:
+        type: integer
+    type: object
+  proto.VersionInfo:
+    properties:
+      commit:
+        type: string
+      go_version:
+        type: string
+      platform:
+        type: string
+      version:
+        type: string
+    type: object
+  proto.Workspace:
+    properties:
+      config:
+        $ref: '#/definitions/github_com_charmbracelet_crush_internal_config.Config'
+      data_dir:
+        type: string
+      debug:
+        type: boolean
+      env:
+        items:
+          type: string
+        type: array
+      id:
+        type: string
+      path:
+        type: string
+      version:
+        type: string
+      yolo:
+        type: boolean
+    type: object
+  time.Duration:
+    enum:
+    - -9223372036854775808
+    - 9223372036854775807
+    - 1
+    - 1000
+    - 1000000
+    - 1000000000
+    - 60000000000
+    - 3600000000000
+    format: int64
+    type: integer
+    x-enum-varnames:
+    - minDuration
+    - maxDuration
+    - Nanosecond
+    - Microsecond
+    - Millisecond
+    - Second
+    - Minute
+    - Hour
+info:
+  contact:
+    name: Charm
+    url: https://charm.sh
+  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.
+  license:
+    name: MIT
+    url: https://github.com/charmbracelet/crush/blob/main/LICENSE
+  title: Crush API
+  version: "1.0"
+paths:
+  /config:
+    get:
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            type: object
+      summary: Get server config
+      tags:
+      - system
+  /control:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Control command (e.g. shutdown)
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.ServerControl'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Send server control command
+      tags:
+      - system
+  /health:
+    get:
+      responses:
+        "200":
+          description: OK
+      summary: Health check
+      tags:
+      - system
+  /version:
+    get:
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.VersionInfo'
+      summary: Get server version
+      tags:
+      - system
+  /workspaces:
+    get:
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            items:
+              $ref: '#/definitions/proto.Workspace'
+            type: array
+      summary: List workspaces
+      tags:
+      - workspaces
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace creation params
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.Workspace'
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.Workspace'
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Create workspace
+      tags:
+      - workspaces
+  /workspaces/{id}:
+    delete:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      responses:
+        "200":
+          description: OK
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Delete workspace
+      tags:
+      - workspaces
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.Workspace'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get workspace
+      tags:
+      - workspaces
+  /workspaces/{id}/agent:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.AgentInfo'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get agent info
+      tags:
+      - agent
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Agent message
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.AgentMessage'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Send message to agent
+      tags:
+      - agent
+  /workspaces/{id}/agent/default-small-model:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Provider ID
+        in: query
+        name: provider_id
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            type: object
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get default small model
+      tags:
+      - agent
+  /workspaces/{id}/agent/init:
+    post:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      responses:
+        "200":
+          description: OK
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Initialize agent
+      tags:
+      - agent
+  /workspaces/{id}/agent/sessions/{sid}:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.AgentSession'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get agent session
+      tags:
+      - agent
+  /workspaces/{id}/agent/sessions/{sid}/cancel:
+    post:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      responses:
+        "200":
+          description: OK
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Cancel agent session
+      tags:
+      - agent
+  /workspaces/{id}/agent/sessions/{sid}/prompts/clear:
+    post:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      responses:
+        "200":
+          description: OK
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Clear prompt queue
+      tags:
+      - agent
+  /workspaces/{id}/agent/sessions/{sid}/prompts/list:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            items:
+              type: string
+            type: array
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: List queued prompts
+      tags:
+      - agent
+  /workspaces/{id}/agent/sessions/{sid}/prompts/queued:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            type: object
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get queued prompt status
+      tags:
+      - agent
+  /workspaces/{id}/agent/sessions/{sid}/summarize:
+    post:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      responses:
+        "200":
+          description: OK
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Summarize session
+      tags:
+      - agent
+  /workspaces/{id}/agent/update:
+    post:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      responses:
+        "200":
+          description: OK
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Update agent
+      tags:
+      - agent
+  /workspaces/{id}/config:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            type: object
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get workspace config
+      tags:
+      - workspaces
+  /workspaces/{id}/config/compact:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Config compact request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.ConfigCompactRequest'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Set compact mode
+      tags:
+      - config
+  /workspaces/{id}/config/import-copilot:
+    post:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.ImportCopilotResponse'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Import Copilot credentials
+      tags:
+      - config
+  /workspaces/{id}/config/model:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Config model request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.ConfigModelRequest'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Set the preferred model
+      tags:
+      - config
+  /workspaces/{id}/config/provider-key:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Config provider key request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.ConfigProviderKeyRequest'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Set provider API key
+      tags:
+      - config
+  /workspaces/{id}/config/refresh-oauth:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Refresh OAuth request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.ConfigRefreshOAuthRequest'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Refresh OAuth token
+      tags:
+      - config
+  /workspaces/{id}/config/remove:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Config remove request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.ConfigRemoveRequest'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Remove a config field
+      tags:
+      - config
+  /workspaces/{id}/config/set:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Config set request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.ConfigSetRequest'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Set a config field
+      tags:
+      - config
+  /workspaces/{id}/events:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - text/event-stream
+      responses:
+        "200":
+          description: OK
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Stream workspace events (SSE)
+      tags:
+      - workspaces
+  /workspaces/{id}/filetracker/lastread:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: query
+        name: session_id
+        type: string
+      - description: File path
+        in: query
+        name: path
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            type: object
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get last read time for file
+      tags:
+      - filetracker
+  /workspaces/{id}/filetracker/read:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: File tracker read request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.FileTrackerReadRequest'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Record file read
+      tags:
+      - filetracker
+  /workspaces/{id}/lsps:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            additionalProperties:
+              $ref: '#/definitions/proto.LSPClientInfo'
+            type: object
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: List LSP clients
+      tags:
+      - lsp
+  /workspaces/{id}/lsps/{lsp}/diagnostics:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: LSP client name
+        in: path
+        name: lsp
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            type: object
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get LSP diagnostics
+      tags:
+      - lsp
+  /workspaces/{id}/lsps/start:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: LSP start request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.LSPStartRequest'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Start LSP server
+      tags:
+      - lsp
+  /workspaces/{id}/lsps/stop:
+    post:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      responses:
+        "200":
+          description: OK
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Stop all LSP servers
+      tags:
+      - lsp
+  /workspaces/{id}/mcp/get-prompt:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: MCP get prompt request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.MCPGetPromptRequest'
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.MCPGetPromptResponse'
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get MCP prompt
+      tags:
+      - mcp
+  /workspaces/{id}/mcp/read-resource:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: MCP read resource request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.MCPReadResourceRequest'
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            type: object
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Read MCP resource
+      tags:
+      - mcp
+  /workspaces/{id}/mcp/refresh-prompts:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: MCP name request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.MCPNameRequest'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Refresh MCP prompts
+      tags:
+      - mcp
+  /workspaces/{id}/mcp/refresh-resources:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: MCP name request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.MCPNameRequest'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Refresh MCP resources
+      tags:
+      - mcp
+  /workspaces/{id}/mcp/refresh-tools:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: MCP name request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.MCPNameRequest'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Refresh MCP tools
+      tags:
+      - mcp
+  /workspaces/{id}/mcp/states:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            additionalProperties:
+              $ref: '#/definitions/proto.MCPClientInfo'
+            type: object
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get MCP client states
+      tags:
+      - mcp
+  /workspaces/{id}/messages/user:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            items:
+              $ref: '#/definitions/github_com_charmbracelet_crush_internal_proto.Message'
+            type: array
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get all user messages for workspace
+      tags:
+      - workspaces
+  /workspaces/{id}/permissions/grant:
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Permission grant
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.PermissionGrant'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Grant permission
+      tags:
+      - permissions
+  /workspaces/{id}/permissions/skip:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.PermissionSkipRequest'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get skip permissions status
+      tags:
+      - permissions
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Permission skip request
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.PermissionSkipRequest'
+      responses:
+        "200":
+          description: OK
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Set skip permissions
+      tags:
+      - permissions
+  /workspaces/{id}/project/init:
+    post:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      responses:
+        "200":
+          description: OK
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Mark project as initialized
+      tags:
+      - project
+  /workspaces/{id}/project/init-prompt:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.ProjectInitPromptResponse'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get project initialization prompt
+      tags:
+      - project
+  /workspaces/{id}/project/needs-init:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.ProjectNeedsInitResponse'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Check if project needs initialization
+      tags:
+      - project
+  /workspaces/{id}/providers:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            type: object
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get workspace providers
+      tags:
+      - workspaces
+  /workspaces/{id}/sessions:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            items:
+              $ref: '#/definitions/proto.Session'
+            type: array
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: List sessions
+      tags:
+      - sessions
+    post:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session creation params (title)
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.Session'
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.Session'
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Create session
+      tags:
+      - sessions
+  /workspaces/{id}/sessions/{sid}:
+    delete:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      responses:
+        "200":
+          description: OK
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Delete session
+      tags:
+      - sessions
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.Session'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get session
+      tags:
+      - sessions
+    put:
+      consumes:
+      - application/json
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      - description: Updated session
+        in: body
+        name: request
+        required: true
+        schema:
+          $ref: '#/definitions/proto.Session'
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/proto.Session'
+        "400":
+          description: Bad Request
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Update session
+      tags:
+      - sessions
+  /workspaces/{id}/sessions/{sid}/filetracker/files:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            items:
+              type: string
+            type: array
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: List tracked files for session
+      tags:
+      - filetracker
+  /workspaces/{id}/sessions/{sid}/history:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            items:
+              $ref: '#/definitions/proto.File'
+            type: array
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get session history
+      tags:
+      - sessions
+  /workspaces/{id}/sessions/{sid}/messages:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            items:
+              $ref: '#/definitions/github_com_charmbracelet_crush_internal_proto.Message'
+            type: array
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get session messages
+      tags:
+      - sessions
+  /workspaces/{id}/sessions/{sid}/messages/user:
+    get:
+      parameters:
+      - description: Workspace ID
+        in: path
+        name: id
+        required: true
+        type: string
+      - description: Session ID
+        in: path
+        name: sid
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            items:
+              $ref: '#/definitions/github_com_charmbracelet_crush_internal_proto.Message'
+            type: array
+        "404":
+          description: Not Found
+          schema:
+            $ref: '#/definitions/proto.Error'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/proto.Error'
+      summary: Get user messages for session
+      tags:
+      - sessions
+swagger: "2.0"

internal/ui/common/common.go 🔗

@@ -7,10 +7,10 @@ import (
 
 	tea "charm.land/bubbletea/v2"
 	"github.com/atotto/clipboard"
-	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/crush/internal/ui/util"
+	"github.com/charmbracelet/crush/internal/workspace"
 	uv "github.com/charmbracelet/ultraviolet"
 )
 
@@ -22,26 +22,21 @@ var AllowedImageTypes = []string{".jpg", ".jpeg", ".png"}
 
 // Common defines common UI options and configurations.
 type Common struct {
-	App    *app.App
-	Styles *styles.Styles
+	Workspace workspace.Workspace
+	Styles    *styles.Styles
 }
 
 // Config returns the pure-data configuration associated with this [Common] instance.
 func (c *Common) Config() *config.Config {
-	return c.App.Config()
-}
-
-// Store returns the config store associated with this [Common] instance.
-func (c *Common) Store() *config.ConfigStore {
-	return c.App.Store()
+	return c.Workspace.Config()
 }
 
 // DefaultCommon returns the default common UI configurations.
-func DefaultCommon(app *app.App) *Common {
+func DefaultCommon(ws workspace.Workspace) *Common {
 	s := styles.DefaultStyles()
 	return &Common{
-		App:    app,
-		Styles: &s,
+		Workspace: ws,
+		Styles:    &s,
 	}
 }
 

internal/ui/dialog/api_key_input.go 🔗

@@ -291,7 +291,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
 		Type:    m.provider.Type,
 		BaseURL: m.provider.APIEndpoint,
 	}
-	err := providerConfig.TestConnection(m.com.Store().Resolver())
+	err := providerConfig.TestConnection(m.com.Workspace.Resolver())
 
 	// intentionally wait for at least 750ms to make sure the user sees the spinner
 	elapsed := time.Since(start)
@@ -307,9 +307,7 @@ func (m *APIKeyInput) verifyAPIKey() tea.Msg {
 }
 
 func (m *APIKeyInput) saveKeyAndContinue() Action {
-	store := m.com.Store()
-
-	err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value())
+	err := m.com.Workspace.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.input.Value())
 	if err != nil {
 		return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
 	}

internal/ui/dialog/filepicker.go 🔗

@@ -123,7 +123,7 @@ func (f *FilePicker) SetImageCapabilities(caps *common.Capabilities) {
 
 // WorkingDir returns the current working directory of the [FilePicker].
 func (f *FilePicker) WorkingDir() string {
-	wd := f.com.Store().WorkingDir()
+	wd := f.com.Workspace.WorkingDir()
 	if len(wd) > 0 {
 		return wd
 	}

internal/ui/dialog/models.go 🔗

@@ -485,7 +485,7 @@ func (m *Models) setProviderItems() error {
 
 		if len(validRecentItems) != len(recentItems) {
 			// FIXME: Does this need to be here? Is it mutating the config during a read?
-			if err := m.com.Store().SetConfigField(config.ScopeGlobal, fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
+			if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, fmt.Sprintf("recent_models.%s", selectedType), validRecentItems); err != nil {
 				return fmt.Errorf("failed to update recent models: %w", err)
 			}
 		}

internal/ui/dialog/oauth.go 🔗

@@ -373,9 +373,7 @@ func (d *OAuth) copyCodeAndOpenURL() tea.Cmd {
 }
 
 func (m *OAuth) saveKeyAndContinue() Action {
-	store := m.com.Store()
-
-	err := store.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token)
+	err := m.com.Workspace.SetProviderAPIKey(config.ScopeGlobal, string(m.provider.ID), m.token)
 	if err != nil {
 		return ActionCmd{util.ReportError(fmt.Errorf("failed to save API key: %w", err))}
 	}

internal/ui/dialog/sessions.go 🔗

@@ -61,7 +61,7 @@ func NewSessions(com *common.Common, selectedSessionID string) (*Session, error)
 	s := new(Session)
 	s.sessionsMode = sessionsModeNormal
 	s.com = com
-	sessions, err := com.App.Sessions.List(context.TODO())
+	sessions, err := com.Workspace.ListSessions(context.TODO())
 	if err != nil {
 		return nil, err
 	}
@@ -349,7 +349,7 @@ func (s *Session) removeSession(id string) {
 
 func (s *Session) deleteSessionCmd(id string) tea.Cmd {
 	return func() tea.Msg {
-		err := s.com.App.Sessions.Delete(context.TODO(), id)
+		err := s.com.Workspace.DeleteSession(context.TODO(), id)
 		if err != nil {
 			return util.NewErrorMsg(err)
 		}
@@ -385,7 +385,7 @@ func (s *Session) updateSession(session session.Session) {
 
 func (s *Session) updateSessionCmd(session session.Session) tea.Cmd {
 	return func() tea.Msg {
-		_, err := s.com.App.Sessions.Save(context.TODO(), session)
+		_, err := s.com.Workspace.SaveSession(context.TODO(), session)
 		if err != nil {
 			return util.NewErrorMsg(err)
 		}
@@ -399,11 +399,11 @@ func (s *Session) isCurrentSessionBusy() bool {
 		return false
 	}
 
-	if s.com.App.AgentCoordinator == nil {
+	if !s.com.Workspace.AgentIsReady() {
 		return false
 	}
 
-	return s.com.App.AgentCoordinator.IsSessionBusy(sessionItem.ID())
+	return s.com.Workspace.AgentIsSessionBusy(sessionItem.ID())
 }
 
 // ShortHelp implements [help.KeyMap].

internal/ui/model/header.go 🔗

@@ -6,9 +6,7 @@ import (
 
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/fsext"
-	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
@@ -62,7 +60,7 @@ func (h *header) drawHeader(
 	h.width = width
 	h.compact = compact
 
-	if !compact || session == nil || h.com.App == nil {
+	if !compact || session == nil {
 		uv.NewStyledString(h.logo).Draw(scr, area)
 		return
 	}
@@ -75,10 +73,14 @@ func (h *header) drawHeader(
 	b.WriteString(h.compactLogo)
 
 	availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - diagToDetailsSpacing
+	lspErrorCount := 0
+	for _, info := range h.com.Workspace.LSPGetStates() {
+		lspErrorCount += info.DiagnosticCount
+	}
 	details := renderHeaderDetails(
 		h.com,
 		session,
-		h.com.App.LSPManager.Clients(),
+		lspErrorCount,
 		detailsOpen,
 		availDetailWidth,
 	)
@@ -108,7 +110,7 @@ func (h *header) drawHeader(
 func renderHeaderDetails(
 	com *common.Common,
 	session *session.Session,
-	lspClients *csync.Map[string, *lsp.Client],
+	lspErrorCount int,
 	detailsOpen bool,
 	availWidth int,
 ) string {
@@ -116,20 +118,17 @@ func renderHeaderDetails(
 
 	var parts []string
 
-	errorCount := 0
-	for l := range lspClients.Seq() {
-		errorCount += l.GetDiagnosticCounts().Error
-	}
-
-	if errorCount > 0 {
-		parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, errorCount)))
+	if lspErrorCount > 0 {
+		parts = append(parts, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s%d", styles.LSPErrorIcon, lspErrorCount)))
 	}
 
 	agentCfg := com.Config().Agents[config.AgentCoder]
 	model := com.Config().GetModelByType(agentCfg.Model)
-	percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
-	formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
-	parts = append(parts, formattedPercentage)
+	if model != nil && model.ContextWindow > 0 {
+		percentage := (float64(session.CompletionTokens+session.PromptTokens) / float64(model.ContextWindow)) * 100
+		formattedPercentage := t.Header.Percentage.Render(fmt.Sprintf("%d%%", int(percentage)))
+		parts = append(parts, formattedPercentage)
+	}
 
 	const keystroke = "ctrl+d"
 	if detailsOpen {
@@ -143,7 +142,7 @@ func renderHeaderDetails(
 	metadata = dot + metadata
 
 	const dirTrimLimit = 4
-	cwd := fsext.DirTrim(fsext.PrettyPath(com.Store().WorkingDir()), dirTrimLimit)
+	cwd := fsext.DirTrim(fsext.PrettyPath(com.Workspace.WorkingDir()), dirTrimLimit)
 	cwd = t.Header.WorkingDir.Render(cwd)
 
 	result := cwd + metadata

internal/ui/model/history.go 🔗

@@ -22,9 +22,9 @@ func (m *UI) loadPromptHistory() tea.Cmd {
 		var err error
 
 		if m.session != nil {
-			messages, err = m.com.App.Messages.ListUserMessages(ctx, m.session.ID)
+			messages, err = m.com.Workspace.ListUserMessages(ctx, m.session.ID)
 		} else {
-			messages, err = m.com.App.Messages.ListAllUserMessages(ctx)
+			messages, err = m.com.Workspace.ListAllUserMessages(ctx)
 		}
 		if err != nil {
 			slog.Error("Failed to load prompt history", "error", err)

internal/ui/model/landing.go 🔗

@@ -2,16 +2,16 @@ package model
 
 import (
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/agent"
 	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/workspace"
 	"github.com/charmbracelet/ultraviolet/layout"
 )
 
 // selectedLargeModel returns the currently selected large language model from
 // the agent coordinator, if one exists.
-func (m *UI) selectedLargeModel() *agent.Model {
-	if m.com.App.AgentCoordinator != nil {
-		model := m.com.App.AgentCoordinator.Model()
+func (m *UI) selectedLargeModel() *workspace.AgentModel {
+	if m.com.Workspace.AgentIsReady() {
+		model := m.com.Workspace.AgentModel()
 		return &model
 	}
 	return nil
@@ -22,7 +22,7 @@ func (m *UI) selectedLargeModel() *agent.Model {
 func (m *UI) landingView() string {
 	t := m.com.Styles
 	width := m.layout.main.Dx()
-	cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width)
+	cwd := common.PrettyPath(t, m.com.Workspace.WorkingDir(), width)
 
 	parts := []string{
 		cwd,

internal/ui/model/lsp.go 🔗

@@ -7,16 +7,16 @@ import (
 	"strings"
 
 	"charm.land/lipgloss/v2"
-	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/lsp"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/crush/internal/workspace"
 	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
 )
 
 // LSPInfo wraps LSP client information with diagnostic counts by severity.
 type LSPInfo struct {
-	app.LSPClientInfo
+	workspace.LSPClientInfo
 	Diagnostics map[protocol.DiagnosticSeverity]int
 }
 
@@ -25,20 +25,18 @@ type LSPInfo struct {
 func (m *UI) lspInfo(width, maxItems int, isSection bool) string {
 	t := m.com.Styles
 
-	states := slices.SortedFunc(maps.Values(m.lspStates), func(a, b app.LSPClientInfo) int {
+	states := slices.SortedFunc(maps.Values(m.lspStates), func(a, b workspace.LSPClientInfo) int {
 		return strings.Compare(a.Name, b.Name)
 	})
 
 	var lsps []LSPInfo
 	for _, state := range states {
 		lspErrs := map[protocol.DiagnosticSeverity]int{}
-		if client, ok := m.com.App.LSPManager.Clients().Get(state.Name); ok {
-			counts := client.GetDiagnosticCounts()
-			lspErrs[protocol.SeverityError] = counts.Error
-			lspErrs[protocol.SeverityWarning] = counts.Warning
-			lspErrs[protocol.SeverityHint] = counts.Hint
-			lspErrs[protocol.SeverityInformation] = counts.Information
-		}
+		counts := m.com.Workspace.LSPGetDiagnosticCounts(state.Name)
+		lspErrs[protocol.SeverityError] = counts.Error
+		lspErrs[protocol.SeverityWarning] = counts.Warning
+		lspErrs[protocol.SeverityHint] = counts.Hint
+		lspErrs[protocol.SeverityInformation] = counts.Information
 
 		lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
 	}

internal/ui/model/onboarding.go 🔗

@@ -9,8 +9,6 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 
-	"github.com/charmbracelet/crush/internal/agent"
-	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/util"
@@ -19,7 +17,7 @@ import (
 // markProjectInitialized marks the current project as initialized in the config.
 func (m *UI) markProjectInitialized() tea.Msg {
 	// TODO: handle error so we show it in the tui footer
-	err := config.MarkProjectInitialized(m.com.Store())
+	err := m.com.Workspace.MarkProjectInitialized()
 	if err != nil {
 		slog.Error(err.Error())
 	}
@@ -52,12 +50,13 @@ func (m *UI) initializeProject() tea.Cmd {
 	if cmd := m.newSession(); cmd != nil {
 		cmds = append(cmds, cmd)
 	}
-	cfg := m.com.Store()
-
 	initialize := func() tea.Msg {
-		initPrompt, err := agent.InitializePrompt(cfg)
+		initPrompt, err := m.com.Workspace.InitializePrompt()
 		if err != nil {
-			return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()}
+			return util.InfoMsg{
+				Type: util.InfoTypeError,
+				Msg:  fmt.Sprintf("Failed to initialize project: %v", err),
+			}
 		}
 		return sendMessageMsg{Content: initPrompt}
 	}
@@ -78,7 +77,7 @@ func (m *UI) skipInitializeProject() tea.Cmd {
 // initializeView renders the project initialization prompt with Yes/No buttons.
 func (m *UI) initializeView() string {
 	s := m.com.Styles.Initialize
-	cwd := home.Short(m.com.Store().WorkingDir())
+	cwd := home.Short(m.com.Workspace.WorkingDir())
 	initFile := m.com.Config().Options.InitializeAs
 
 	header := s.Header.Render("Would you like to initialize this project?")

internal/ui/model/pills.go 🔗

@@ -249,8 +249,8 @@ func (m *UI) renderPills() {
 		if todosFocused && hasIncomplete {
 			expandedList = todoList(m.session.Todos, inProgressIcon, t, contentWidth)
 		} else if queueFocused && hasQueue {
-			if m.com.App != nil && m.com.App.AgentCoordinator != nil {
-				queueItems := m.com.App.AgentCoordinator.QueuedPromptsList(m.session.ID)
+			if m.com.Workspace.AgentIsReady() {
+				queueItems := m.com.Workspace.AgentQueuedPromptsList(m.session.ID)
 				expandedList = queueList(queueItems, t)
 			}
 		}

internal/ui/model/session.go 🔗

@@ -66,7 +66,7 @@ type SessionFile struct {
 // returns a sessionFilesLoadedMsg containing the processed session files.
 func (m *UI) loadSession(sessionID string) tea.Cmd {
 	return func() tea.Msg {
-		session, err := m.com.App.Sessions.Get(context.Background(), sessionID)
+		session, err := m.com.Workspace.GetSession(context.Background(), sessionID)
 		if err != nil {
 			return util.ReportError(err)
 		}
@@ -76,7 +76,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
 			return util.ReportError(err)
 		}
 
-		readFiles, err := m.com.App.FileTracker.ListReadFiles(context.Background(), sessionID)
+		readFiles, err := m.com.Workspace.FileTrackerListReadFiles(context.Background(), sessionID)
 		if err != nil {
 			slog.Error("Failed to load read files for session", "error", err)
 		}
@@ -90,7 +90,7 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
 }
 
 func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
-	files, err := m.com.App.History.ListBySession(context.Background(), sessionID)
+	files, err := m.com.Workspace.ListSessionHistory(context.Background(), sessionID)
 	if err != nil {
 		return nil, err
 	}
@@ -241,7 +241,7 @@ func (m *UI) startLSPs(paths []string) tea.Cmd {
 	return func() tea.Msg {
 		ctx := context.Background()
 		for _, path := range paths {
-			m.com.App.LSPManager.Start(ctx, path)
+			m.com.Workspace.LSPStart(ctx, path)
 		}
 		return nil
 	}

internal/ui/model/sidebar.go 🔗

@@ -48,7 +48,11 @@ func (m *UI) modelInfo(width int) string {
 			ModelContext: model.CatwalkCfg.ContextWindow,
 		}
 	}
-	return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width)
+	var modelName string
+	if model != nil {
+		modelName = model.CatwalkCfg.Name
+	}
+	return common.ModelInfo(m.com.Styles, modelName, providerName, reasoningInfo, modelContext, width)
 }
 
 // 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) {
 	height := area.Dy()
 
 	title := t.Muted.Width(width).MaxHeight(2).Render(m.session.Title)
-	cwd := common.PrettyPath(t, m.com.Store().WorkingDir(), width)
+	cwd := common.PrettyPath(t, m.com.Workspace.WorkingDir(), width)
 	sidebarLogo := m.sidebarLogo
 	if height < logoHeightBreakpoint {
 		sidebarLogo = logo.SmallRender(m.com.Styles, width)
@@ -138,7 +142,7 @@ func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {
 
 	lspSection := m.lspInfo(width, maxLSPs, true)
 	mcpSection := m.mcpInfo(width, maxMCPs, true)
-	filesSection := m.filesInfo(m.com.Store().WorkingDir(), width, maxFiles, true)
+	filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), width, maxFiles, true)
 
 	uv.NewStyledString(
 		lipgloss.NewStyle().

internal/ui/model/ui.go 🔗

@@ -28,7 +28,6 @@ import (
 	"github.com/charmbracelet/crush/internal/agent/notify"
 	agenttools "github.com/charmbracelet/crush/internal/agent/tools"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
-	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/fsext"
@@ -50,6 +49,7 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	"github.com/charmbracelet/crush/internal/ui/util"
 	"github.com/charmbracelet/crush/internal/version"
+	"github.com/charmbracelet/crush/internal/workspace"
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/ultraviolet/layout"
 	"github.com/charmbracelet/ultraviolet/screen"
@@ -213,7 +213,7 @@ type UI struct {
 	}
 
 	// lsp
-	lspStates map[string]app.LSPClientInfo
+	lspStates map[string]workspace.LSPClientInfo
 
 	// mcp
 	mcpStates map[string]mcp.ClientInfo
@@ -315,7 +315,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
 		completions:         comp,
 		attachments:         attachments,
 		todoSpinner:         todoSpinner,
-		lspStates:           make(map[string]app.LSPClientInfo),
+		lspStates:           make(map[string]workspace.LSPClientInfo),
 		mcpStates:           make(map[string]mcp.ClientInfo),
 		notifyBackend:       notification.NoopBackend{},
 		notifyWindowFocused: true,
@@ -340,7 +340,7 @@ func New(com *common.Common, initialSessionID string, continueLast bool) *UI {
 	desiredFocus := uiFocusEditor
 	if !com.Config().IsConfigured() {
 		desiredState = uiOnboarding
-	} else if n, _ := config.ProjectNeedsInitialization(com.Store()); n {
+	} else if n, _ := com.Workspace.ProjectNeedsInitialization(); n {
 		desiredState = uiInitialize
 	}
 
@@ -386,11 +386,11 @@ func (m *UI) loadInitialSession() tea.Cmd {
 		return m.loadSession(m.initialSessionID)
 	case m.continueLastSession:
 		return func() tea.Msg {
-			sess, err := m.com.App.Sessions.GetLast(context.Background())
-			if err != nil {
+			sessions, err := m.com.Workspace.ListSessions(context.Background())
+			if err != nil || len(sessions) == 0 {
 				return nil
 			}
-			return m.loadSession(sess.ID)()
+			return m.loadSession(sessions[0].ID)()
 		}
 	default:
 		return nil
@@ -463,7 +463,7 @@ func (m *UI) loadMCPrompts() tea.Msg {
 func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	if m.hasSession() && m.isAgentBusy() {
-		queueSize := m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID)
+		queueSize := m.com.Workspace.AgentQueuedPrompts(m.session.ID)
 		if queueSize != m.promptQueue {
 			m.promptQueue = queueSize
 			m.updateLayoutAndSize()
@@ -498,7 +498,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.session = msg.session
 		m.sessionFiles = msg.files
 		cmds = append(cmds, m.startLSPs(msg.lspFilePaths()))
-		msgs, err := m.com.App.Messages.List(context.Background(), m.session.ID)
+		msgs, err := m.com.Workspace.ListMessages(context.Background(), m.session.ID)
 		if err != nil {
 			cmds = append(cmds, util.ReportError(err))
 			break
@@ -615,8 +615,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.renderPills()
 	case pubsub.Event[history.File]:
 		cmds = append(cmds, m.handleFileEvent(msg.Payload))
-	case pubsub.Event[app.LSPEvent]:
-		m.lspStates = app.GetLSPStates()
+	case pubsub.Event[workspace.LSPEvent]:
+		m.lspStates = m.com.Workspace.LSPGetStates()
 	case pubsub.Event[mcp.Event]:
 		switch msg.Payload.Type {
 		case mcp.EventStateChanged:
@@ -625,11 +625,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.loadMCPrompts,
 			)
 		case mcp.EventPromptsListChanged:
-			return m, handleMCPPromptsEvent(msg.Payload.Name)
+			return m, handleMCPPromptsEvent(m.com.Workspace, msg.Payload.Name)
 		case mcp.EventToolsListChanged:
-			return m, handleMCPToolsEvent(m.com.Store(), msg.Payload.Name)
+			return m, handleMCPToolsEvent(m.com.Workspace, msg.Payload.Name)
 		case mcp.EventResourcesListChanged:
-			return m, handleMCPResourcesEvent(msg.Payload.Name)
+			return m, handleMCPResourcesEvent(m.com.Workspace, msg.Payload.Name)
 		}
 	case pubsub.Event[permission.PermissionRequest]:
 		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -836,6 +836,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.textarea.MoveToEnd()
 		cmds = append(cmds, m.updateTextareaWithPrevHeight(msg, prevHeight))
 	case util.InfoMsg:
+		if msg.Type == util.InfoTypeError {
+			slog.Error("Error reported", "error", msg.Msg)
+		}
 		m.status.SetInfoMsg(msg)
 		ttl := msg.TTL
 		if ttl <= 0 {
@@ -872,7 +875,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		} else {
 			m.textarea.Placeholder = m.readyPlaceholder
 		}
-		if m.com.App.Permissions.SkipRequests() {
+		if m.com.Workspace.PermissionSkipRequests() {
 			m.textarea.Placeholder = "Yolo mode!"
 		}
 	}
@@ -951,10 +954,10 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
 		messageID := toolItem.MessageID()
 
 		// Get the agent tool session ID.
-		agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(messageID, tc.ID)
+		agentSessionID := m.com.Workspace.CreateAgentToolSessionID(messageID, tc.ID)
 
 		// Fetch nested messages.
-		nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
+		nestedMsgs, err := m.com.Workspace.ListMessages(context.Background(), agentSessionID)
 		if err != nil || len(nestedMsgs) == 0 {
 			continue
 		}
@@ -1156,7 +1159,7 @@ func (m *UI) handleChildSessionMessage(event pubsub.Event[message.Message]) tea.
 
 	// Check if this is an agent tool session and parse it.
 	childSessionID := event.Payload.SessionID
-	_, toolCallID, ok := m.com.App.Sessions.ParseAgentToolSessionID(childSessionID)
+	_, toolCallID, ok := m.com.Workspace.ParseAgentToolSessionID(childSessionID)
 	if !ok {
 		return nil
 	}
@@ -1288,8 +1291,8 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 
 	// Command dialog messages.
 	case dialog.ActionToggleYoloMode:
-		yolo := !m.com.App.Permissions.SkipRequests()
-		m.com.App.Permissions.SetSkipRequests(yolo)
+		yolo := !m.com.Workspace.PermissionSkipRequests()
+		m.com.Workspace.PermissionSetSkipRequests(yolo)
 		m.setEditorPrompt(yolo)
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionToggleNotifications:
@@ -1297,7 +1300,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		if cfg != nil && cfg.Options != nil {
 			disabled := !cfg.Options.DisableNotifications
 			cfg.Options.DisableNotifications = disabled
-			if err := m.com.Store().SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
+			if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.disable_notifications", disabled); err != nil {
 				cmds = append(cmds, util.ReportError(err))
 			} else {
 				status := "enabled"
@@ -1323,7 +1326,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			break
 		}
 		cmds = append(cmds, func() tea.Msg {
-			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
+			err := m.com.Workspace.AgentSummarize(context.Background(), msg.SessionID)
 			if err != nil {
 				return util.ReportError(err)()
 			}
@@ -1362,10 +1365,10 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 
 			currentModel := cfg.Models[agentCfg.Model]
 			currentModel.Think = !currentModel.Think
-			if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
+			if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
 				return util.ReportError(err)()
 			}
-			m.com.App.UpdateAgentModel(context.TODO())
+			m.com.Workspace.UpdateAgentModel(context.TODO())
 			status := "disabled"
 			if currentModel.Think {
 				status = "enabled"
@@ -1382,7 +1385,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 
 			isTransparent := cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent
 			newValue := !isTransparent
-			if err := m.com.Store().SetTransparentBackground(config.ScopeGlobal, newValue); err != nil {
+			if err := m.com.Workspace.SetConfigField(config.ScopeGlobal, "options.tui.transparent", newValue); err != nil {
 				return util.ReportError(err)()
 			}
 			m.isTransparent = newValue
@@ -1430,7 +1433,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 
 		// Attempt to import GitHub Copilot tokens from VSCode if available.
 		if isCopilot && !isConfigured() && !msg.ReAuthenticate {
-			m.com.Store().ImportCopilot()
+			m.com.Workspace.ImportCopilot()
 		}
 
 		if !isConfigured() || msg.ReAuthenticate {
@@ -1441,18 +1444,18 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 			break
 		}
 
-		if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
+		if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, msg.ModelType, msg.Model); err != nil {
 			cmds = append(cmds, util.ReportError(err))
 		} else if _, ok := cfg.Models[config.SelectedModelTypeSmall]; !ok {
 			// Ensure small model is set is unset.
-			smallModel := m.com.App.GetDefaultSmallModel(providerID)
-			if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
+			smallModel := m.com.Workspace.GetDefaultSmallModel(providerID)
+			if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, config.SelectedModelTypeSmall, smallModel); err != nil {
 				cmds = append(cmds, util.ReportError(err))
 			}
 		}
 
 		cmds = append(cmds, func() tea.Msg {
-			if err := m.com.App.UpdateAgentModel(context.TODO()); err != nil {
+			if err := m.com.Workspace.UpdateAgentModel(context.TODO()); err != nil {
 				return util.ReportError(err)
 			}
 
@@ -1468,7 +1471,7 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		if isOnboarding {
 			m.setState(uiLanding, uiFocusEditor)
 			m.com.Config().SetupAgents()
-			if err := m.com.App.InitCoderAgent(context.TODO()); err != nil {
+			if err := m.com.Workspace.InitCoderAgent(context.TODO()); err != nil {
 				cmds = append(cmds, util.ReportError(err))
 			}
 		}
@@ -1492,13 +1495,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 
 		currentModel := cfg.Models[agentCfg.Model]
 		currentModel.ReasoningEffort = msg.Effort
-		if err := m.com.Store().UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
+		if err := m.com.Workspace.UpdatePreferredModel(config.ScopeGlobal, agentCfg.Model, currentModel); err != nil {
 			cmds = append(cmds, util.ReportError(err))
 			break
 		}
 
 		cmds = append(cmds, func() tea.Msg {
-			m.com.App.UpdateAgentModel(context.TODO())
+			m.com.Workspace.UpdateAgentModel(context.TODO())
 			return util.NewInfoMsg("Reasoning effort set to " + msg.Effort)
 		})
 		m.dialog.CloseDialog(dialog.ReasoningID)
@@ -1506,11 +1509,11 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.dialog.CloseDialog(dialog.PermissionsID)
 		switch msg.Action {
 		case dialog.PermissionAllow:
-			m.com.App.Permissions.Grant(msg.Permission)
+			m.com.Workspace.PermissionGrant(msg.Permission)
 		case dialog.PermissionAllowForSession:
-			m.com.App.Permissions.GrantPersistent(msg.Permission)
+			m.com.Workspace.PermissionGrantPersistent(msg.Permission)
 		case dialog.PermissionDeny:
-			m.com.App.Permissions.Deny(msg.Permission)
+			m.com.Workspace.PermissionDeny(msg.Permission)
 		}
 
 	case dialog.ActionFilePickerSelected:
@@ -2115,7 +2118,7 @@ func (m *UI) View() tea.View {
 	}
 	v.MouseMode = tea.MouseModeCellMotion
 	v.ReportFocus = m.caps.ReportFocusEvents
-	v.WindowTitle = "crush " + home.Short(m.com.Store().WorkingDir())
+	v.WindowTitle = "crush " + home.Short(m.com.Workspace.WorkingDir())
 
 	canvas := uv.NewScreenBuffer(m.width, m.height)
 	v.Cursor = m.Draw(canvas, canvas.Bounds())
@@ -2158,7 +2161,7 @@ func (m *UI) ShortHelp() []key.Binding {
 			cancelBinding := k.Chat.Cancel
 			if m.isCanceling {
 				cancelBinding.SetHelp("esc", "press again to cancel")
-			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
+			} else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
 				cancelBinding.SetHelp("esc", "clear queue")
 			}
 			binds = append(binds, cancelBinding)
@@ -2237,7 +2240,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 			cancelBinding := k.Chat.Cancel
 			if m.isCanceling {
 				cancelBinding.SetHelp("esc", "press again to cancel")
-			} else if m.com.App.AgentCoordinator.QueuedPrompts(m.session.ID) > 0 {
+			} else if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
 				cancelBinding.SetHelp("esc", "clear queue")
 			}
 			binds = append(binds, []key.Binding{cancelBinding})
@@ -2364,7 +2367,7 @@ func (m *UI) currentModelSupportsImages() bool {
 func (m *UI) toggleCompactMode() tea.Cmd {
 	m.forceCompactMode = !m.forceCompactMode
 
-	err := m.com.Store().SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
+	err := m.com.Workspace.SetCompactMode(config.ScopeGlobal, m.forceCompactMode)
 	if err != nil {
 		return util.ReportError(err)
 	}
@@ -2751,7 +2754,7 @@ func (m *UI) insertFileCompletion(path string) tea.Cmd {
 
 		if m.hasSession() {
 			// Skip attachment if file was already read and hasn't been modified.
-			lastRead := m.com.App.FileTracker.LastReadTime(context.Background(), m.session.ID, absPath)
+			lastRead := m.com.Workspace.FileTrackerLastReadTime(context.Background(), m.session.ID, absPath)
 			if !lastRead.IsZero() {
 				if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
 					return nil
@@ -2792,9 +2795,8 @@ func (m *UI) insertMCPResourceCompletion(item completions.ResourceCompletionValu
 	heightCmd := m.handleTextareaHeightChange(prevHeight)
 
 	resourceCmd := func() tea.Msg {
-		contents, err := mcp.ReadResource(
+		contents, err := m.com.Workspace.ReadMCPResource(
 			context.Background(),
-			m.com.Store(),
 			item.MCPName,
 			item.URI,
 		)
@@ -2863,9 +2865,8 @@ func isWhitespace(b byte) bool {
 // isAgentBusy returns true if the agent coordinator exists and is currently
 // busy processing a request.
 func (m *UI) isAgentBusy() bool {
-	return m.com.App != nil &&
-		m.com.App.AgentCoordinator != nil &&
-		m.com.App.AgentCoordinator.IsBusy()
+	return m.com.Workspace.AgentIsReady() &&
+		m.com.Workspace.AgentIsBusy()
 }
 
 // hasSession returns true if there is an active session with a valid ID.
@@ -2922,13 +2923,13 @@ func (m *UI) cacheSidebarLogo(width int) {
 
 // sendMessage sends a message with the given content and attachments.
 func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
-	if m.com.App.AgentCoordinator == nil {
+	if !m.com.Workspace.AgentIsReady() {
 		return util.ReportError(fmt.Errorf("coder agent is not initialized"))
 	}
 
 	var cmds []tea.Cmd
 	if !m.hasSession() {
-		newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
+		newSession, err := m.com.Workspace.CreateSession(context.Background(), "New Session")
 		if err != nil {
 			return util.ReportError(err)
 		}
@@ -2945,8 +2946,8 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
 	ctx := context.Background()
 	cmds = append(cmds, func() tea.Msg {
 		for _, path := range m.sessionFileReads {
-			m.com.App.FileTracker.RecordRead(ctx, m.session.ID, path)
-			m.com.App.LSPManager.Start(ctx, path)
+			m.com.Workspace.FileTrackerRecordRead(ctx, m.session.ID, path)
+			m.com.Workspace.LSPStart(ctx, path)
 		}
 		return nil
 	})
@@ -2954,7 +2955,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
 	// Capture session ID to avoid race with main goroutine updating m.session.
 	sessionID := m.session.ID
 	cmds = append(cmds, func() tea.Msg {
-		_, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
+		err := m.com.Workspace.AgentRun(context.Background(), sessionID, content, attachments...)
 		if err != nil {
 			isCancelErr := errors.Is(err, context.Canceled)
 			isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
@@ -2963,7 +2964,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
 			}
 			return util.InfoMsg{
 				Type: util.InfoTypeError,
-				Msg:  err.Error(),
+				Msg:  fmt.Sprintf("Failed to run agent: %v", err),
 			}
 		}
 		return nil
@@ -2988,15 +2989,14 @@ func (m *UI) cancelAgent() tea.Cmd {
 		return nil
 	}
 
-	coordinator := m.com.App.AgentCoordinator
-	if coordinator == nil {
+	if !m.com.Workspace.AgentIsReady() {
 		return nil
 	}
 
 	if m.isCanceling {
 		// Second escape press - actually cancel the agent.
 		m.isCanceling = false
-		coordinator.Cancel(m.session.ID)
+		m.com.Workspace.AgentCancel(m.session.ID)
 		// Stop the spinning todo indicator.
 		m.todoIsSpinning = false
 		m.renderPills()
@@ -3004,8 +3004,8 @@ func (m *UI) cancelAgent() tea.Cmd {
 	}
 
 	// Check if there are queued prompts - if so, clear the queue.
-	if coordinator.QueuedPrompts(m.session.ID) > 0 {
-		coordinator.ClearQueue(m.session.ID)
+	if m.com.Workspace.AgentQueuedPrompts(m.session.ID) > 0 {
+		m.com.Workspace.AgentClearQueue(m.session.ID)
 		return nil
 	}
 
@@ -3230,7 +3230,7 @@ func (m *UI) newSession() tea.Cmd {
 	agenttools.ResetCache()
 	return tea.Batch(
 		func() tea.Msg {
-			m.com.App.LSPManager.StopAll(context.Background())
+			m.com.Workspace.LSPStopAll(context.Background())
 			return nil
 		},
 		m.loadPromptHistory(),
@@ -3476,7 +3476,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
 
 	lspSection := m.lspInfo(sectionWidth, maxItemsPerSection, false)
 	mcpSection := m.mcpInfo(sectionWidth, maxItemsPerSection, false)
-	filesSection := m.filesInfo(m.com.Store().WorkingDir(), sectionWidth, maxItemsPerSection, false)
+	filesSection := m.filesInfo(m.com.Workspace.WorkingDir(), sectionWidth, maxItemsPerSection, false)
 	sections := lipgloss.JoinHorizontal(lipgloss.Top, filesSection, " ", lspSection, " ", mcpSection)
 	uv.NewStyledString(
 		s.CompactDetails.View.
@@ -3494,7 +3494,7 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
 
 func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
 	load := func() tea.Msg {
-		prompt, err := commands.GetMCPPrompt(m.com.Store(), clientID, promptID, arguments)
+		prompt, err := m.com.Workspace.GetMCPPrompt(clientID, promptID, arguments)
 		if err != nil {
 			// TODO: make this better
 			return util.ReportError(err)()
@@ -3521,34 +3521,30 @@ func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string
 
 func (m *UI) handleStateChanged() tea.Cmd {
 	return func() tea.Msg {
-		m.com.App.UpdateAgentModel(context.Background())
+		m.com.Workspace.UpdateAgentModel(context.Background())
 		return mcpStateChangedMsg{
-			states: mcp.GetStates(),
+			states: m.com.Workspace.MCPGetStates(),
 		}
 	}
 }
 
-func handleMCPPromptsEvent(name string) tea.Cmd {
+func handleMCPPromptsEvent(ws workspace.Workspace, name string) tea.Cmd {
 	return func() tea.Msg {
-		mcp.RefreshPrompts(context.Background(), name)
+		ws.MCPRefreshPrompts(context.Background(), name)
 		return nil
 	}
 }
 
-func handleMCPToolsEvent(cfg *config.ConfigStore, name string) tea.Cmd {
+func handleMCPToolsEvent(ws workspace.Workspace, name string) tea.Cmd {
 	return func() tea.Msg {
-		mcp.RefreshTools(
-			context.Background(),
-			cfg,
-			name,
-		)
+		ws.RefreshMCPTools(context.Background(), name)
 		return nil
 	}
 }
 
-func handleMCPResourcesEvent(name string) tea.Cmd {
+func handleMCPResourcesEvent(ws workspace.Workspace, name string) tea.Cmd {
 	return func() tea.Msg {
-		mcp.RefreshResources(context.Background(), name)
+		ws.MCPRefreshResources(context.Background(), name)
 		return nil
 	}
 }
@@ -3566,40 +3562,16 @@ func (m *UI) copyChatHighlight() tea.Cmd {
 }
 
 func (m *UI) enableDockerMCP() tea.Msg {
-	store := m.com.Store()
-	// Stage Docker MCP in memory first so startup and persistence can be atomic.
-	mcpConfig, err := store.PrepareDockerMCPConfig()
-	if err != nil {
-		return util.ReportError(err)()
-	}
-
 	ctx := context.Background()
-	if err := mcp.InitializeSingle(ctx, config.DockerMCPName, store); err != nil {
-		// Roll back runtime and in-memory state when startup fails.
-		disableErr := mcp.DisableSingle(store, config.DockerMCPName)
-		delete(store.Config().MCP, config.DockerMCPName)
-		return util.ReportError(fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr)))()
-	}
-
-	if err := store.PersistDockerMCPConfig(mcpConfig); err != nil {
-		// Roll back runtime and in-memory state if persistence fails.
-		disableErr := mcp.DisableSingle(store, config.DockerMCPName)
-		delete(store.Config().MCP, config.DockerMCPName)
-		return util.ReportError(fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr)))()
+	if err := m.com.Workspace.EnableDockerMCP(ctx); err != nil {
+		return util.ReportError(err)()
 	}
 
 	return util.NewInfoMsg("Docker MCP enabled and started successfully")
 }
 
 func (m *UI) disableDockerMCP() tea.Msg {
-	store := m.com.Store()
-	// Close the Docker MCP client.
-	if err := mcp.DisableSingle(store, config.DockerMCPName); err != nil {
-		return util.ReportError(fmt.Errorf("failed to disable docker MCP: %w", err))()
-	}
-
-	// Remove from config and persist.
-	if err := store.DisableDockerMCP(); err != nil {
+	if err := m.com.Workspace.DisableDockerMCP(); err != nil {
 		return util.ReportError(err)()
 	}
 

internal/ui/model/ui_test.go 🔗

@@ -1,15 +1,13 @@
 package model
 
 import (
-	"reflect"
 	"testing"
-	"unsafe"
 
 	"charm.land/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/ui/common"
+	"github.com/charmbracelet/crush/internal/workspace"
 	"github.com/stretchr/testify/require"
 )
 
@@ -79,29 +77,19 @@ func TestCurrentModelSupportsImages(t *testing.T) {
 func newTestUIWithConfig(t *testing.T, cfg *config.Config) *UI {
 	t.Helper()
 
-	store := &config.ConfigStore{}
-	setUnexportedField(t, store, "config", cfg)
-
-	appInstance := &app.App{}
-	setUnexportedField(t, appInstance, "config", store)
-
 	return &UI{
 		com: &common.Common{
-			App: appInstance,
+			Workspace: &testWorkspace{cfg: cfg},
 		},
 	}
 }
 
-func setUnexportedField(t *testing.T, target any, name string, value any) {
-	t.Helper()
-
-	v := reflect.ValueOf(target)
-	require.Equal(t, reflect.Pointer, v.Kind())
-	require.False(t, v.IsNil())
-
-	field := v.Elem().FieldByName(name)
-	require.Truef(t, field.IsValid(), "field %q not found", name)
+// testWorkspace is a minimal [workspace.Workspace] stub for unit tests.
+type testWorkspace struct {
+	workspace.Workspace
+	cfg *config.Config
+}
 
-	fieldValue := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem()
-	fieldValue.Set(reflect.ValueOf(value))
+func (w *testWorkspace) Config() *config.Config {
+	return w.cfg
 }

internal/ui/util/util.go 🔗

@@ -4,7 +4,6 @@ package util
 import (
 	"context"
 	"errors"
-	"log/slog"
 	"os/exec"
 	"time"
 
@@ -23,7 +22,6 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
 }
 
 func ReportError(err error) tea.Cmd {
-	slog.Error("Error reported", "error", err)
 	return CmdHandler(NewErrorMsg(err))
 }
 

internal/version/version.go 🔗

@@ -2,9 +2,12 @@ package version
 
 import "runtime/debug"
 
-// Build-time parameters set via -ldflags
+// Build-time parameters set via -ldflags.
 
-var Version = "devel"
+var (
+	Version = "devel"
+	Commit  = "unknown"
+)
 
 // A user may install crush using `go install github.com/charmbracelet/crush@latest`.
 // without -ldflags, in which case the version above is unset. As a workaround

internal/workspace/app_workspace.go 🔗

@@ -0,0 +1,389 @@
+package workspace
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/agent"
+	mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/commands"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/oauth"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/session"
+)
+
+// AppWorkspace implements the Workspace interface by delegating
+// directly to an in-process [app.App] instance. This is the default
+// mode when the client/server architecture is not enabled.
+type AppWorkspace struct {
+	app   *app.App
+	store *config.ConfigStore
+}
+
+// NewAppWorkspace creates a new AppWorkspace wrapping the given app
+// and config store.
+func NewAppWorkspace(a *app.App, store *config.ConfigStore) *AppWorkspace {
+	return &AppWorkspace{
+		app:   a,
+		store: store,
+	}
+}
+
+// -- Sessions --
+
+func (w *AppWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
+	return w.app.Sessions.Create(ctx, title)
+}
+
+func (w *AppWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
+	return w.app.Sessions.Get(ctx, sessionID)
+}
+
+func (w *AppWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
+	return w.app.Sessions.List(ctx)
+}
+
+func (w *AppWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
+	return w.app.Sessions.Save(ctx, sess)
+}
+
+func (w *AppWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
+	return w.app.Sessions.Delete(ctx, sessionID)
+}
+
+func (w *AppWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
+	return w.app.Sessions.CreateAgentToolSessionID(messageID, toolCallID)
+}
+
+func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
+	return w.app.Sessions.ParseAgentToolSessionID(sessionID)
+}
+
+// -- Messages --
+
+func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
+	return w.app.Messages.List(ctx, sessionID)
+}
+
+func (w *AppWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
+	return w.app.Messages.ListUserMessages(ctx, sessionID)
+}
+
+func (w *AppWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
+	return w.app.Messages.ListAllUserMessages(ctx)
+}
+
+// -- Agent --
+
+func (w *AppWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
+	if w.app.AgentCoordinator == nil {
+		return errors.New("agent coordinator not initialized")
+	}
+	_, err := w.app.AgentCoordinator.Run(ctx, sessionID, prompt, attachments...)
+	return err
+}
+
+func (w *AppWorkspace) AgentCancel(sessionID string) {
+	if w.app.AgentCoordinator != nil {
+		w.app.AgentCoordinator.Cancel(sessionID)
+	}
+}
+
+func (w *AppWorkspace) AgentIsBusy() bool {
+	if w.app.AgentCoordinator == nil {
+		return false
+	}
+	return w.app.AgentCoordinator.IsBusy()
+}
+
+func (w *AppWorkspace) AgentIsSessionBusy(sessionID string) bool {
+	if w.app.AgentCoordinator == nil {
+		return false
+	}
+	return w.app.AgentCoordinator.IsSessionBusy(sessionID)
+}
+
+func (w *AppWorkspace) AgentModel() AgentModel {
+	if w.app.AgentCoordinator == nil {
+		return AgentModel{}
+	}
+	m := w.app.AgentCoordinator.Model()
+	return AgentModel{
+		CatwalkCfg: m.CatwalkCfg,
+		ModelCfg:   m.ModelCfg,
+	}
+}
+
+func (w *AppWorkspace) AgentIsReady() bool {
+	return w.app.AgentCoordinator != nil
+}
+
+func (w *AppWorkspace) AgentQueuedPrompts(sessionID string) int {
+	if w.app.AgentCoordinator == nil {
+		return 0
+	}
+	return w.app.AgentCoordinator.QueuedPrompts(sessionID)
+}
+
+func (w *AppWorkspace) AgentQueuedPromptsList(sessionID string) []string {
+	if w.app.AgentCoordinator == nil {
+		return nil
+	}
+	return w.app.AgentCoordinator.QueuedPromptsList(sessionID)
+}
+
+func (w *AppWorkspace) AgentClearQueue(sessionID string) {
+	if w.app.AgentCoordinator != nil {
+		w.app.AgentCoordinator.ClearQueue(sessionID)
+	}
+}
+
+func (w *AppWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
+	if w.app.AgentCoordinator == nil {
+		return errors.New("agent coordinator not initialized")
+	}
+	return w.app.AgentCoordinator.Summarize(ctx, sessionID)
+}
+
+func (w *AppWorkspace) UpdateAgentModel(ctx context.Context) error {
+	return w.app.UpdateAgentModel(ctx)
+}
+
+func (w *AppWorkspace) InitCoderAgent(ctx context.Context) error {
+	return w.app.InitCoderAgent(ctx)
+}
+
+func (w *AppWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
+	return w.app.GetDefaultSmallModel(providerID)
+}
+
+// -- Permissions --
+
+func (w *AppWorkspace) PermissionGrant(perm permission.PermissionRequest) {
+	w.app.Permissions.Grant(perm)
+}
+
+func (w *AppWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
+	w.app.Permissions.GrantPersistent(perm)
+}
+
+func (w *AppWorkspace) PermissionDeny(perm permission.PermissionRequest) {
+	w.app.Permissions.Deny(perm)
+}
+
+func (w *AppWorkspace) PermissionSkipRequests() bool {
+	return w.app.Permissions.SkipRequests()
+}
+
+func (w *AppWorkspace) PermissionSetSkipRequests(skip bool) {
+	w.app.Permissions.SetSkipRequests(skip)
+}
+
+// -- FileTracker --
+
+func (w *AppWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
+	w.app.FileTracker.RecordRead(ctx, sessionID, path)
+}
+
+func (w *AppWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
+	return w.app.FileTracker.LastReadTime(ctx, sessionID, path)
+}
+
+func (w *AppWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
+	return w.app.FileTracker.ListReadFiles(ctx, sessionID)
+}
+
+// -- History --
+
+func (w *AppWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
+	return w.app.History.ListBySession(ctx, sessionID)
+}
+
+// -- LSP --
+
+func (w *AppWorkspace) LSPStart(ctx context.Context, path string) {
+	w.app.LSPManager.Start(ctx, path)
+}
+
+func (w *AppWorkspace) LSPStopAll(ctx context.Context) {
+	w.app.LSPManager.StopAll(ctx)
+}
+
+func (w *AppWorkspace) LSPGetStates() map[string]LSPClientInfo {
+	states := app.GetLSPStates()
+	result := make(map[string]LSPClientInfo, len(states))
+	for k, v := range states {
+		result[k] = LSPClientInfo{
+			Name:            v.Name,
+			State:           v.State,
+			Error:           v.Error,
+			DiagnosticCount: v.DiagnosticCount,
+			ConnectedAt:     v.ConnectedAt,
+		}
+	}
+	return result
+}
+
+func (w *AppWorkspace) LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts {
+	state, ok := app.GetLSPState(name)
+	if !ok || state.Client == nil {
+		return lsp.DiagnosticCounts{}
+	}
+	return state.Client.GetDiagnosticCounts()
+}
+
+// -- Config (read-only) --
+
+func (w *AppWorkspace) Config() *config.Config {
+	return w.store.Config()
+}
+
+func (w *AppWorkspace) WorkingDir() string {
+	return w.store.WorkingDir()
+}
+
+func (w *AppWorkspace) Resolver() config.VariableResolver {
+	return w.store.Resolver()
+}
+
+// -- Config mutations --
+
+func (w *AppWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
+	return w.store.UpdatePreferredModel(scope, modelType, model)
+}
+
+func (w *AppWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
+	return w.store.SetCompactMode(scope, enabled)
+}
+
+func (w *AppWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
+	return w.store.SetProviderAPIKey(scope, providerID, apiKey)
+}
+
+func (w *AppWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
+	return w.store.SetConfigField(scope, key, value)
+}
+
+func (w *AppWorkspace) RemoveConfigField(scope config.Scope, key string) error {
+	return w.store.RemoveConfigField(scope, key)
+}
+
+func (w *AppWorkspace) ImportCopilot() (*oauth.Token, bool) {
+	return w.store.ImportCopilot()
+}
+
+func (w *AppWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
+	return w.store.RefreshOAuthToken(ctx, scope, providerID)
+}
+
+// -- Project lifecycle --
+
+func (w *AppWorkspace) ProjectNeedsInitialization() (bool, error) {
+	return config.ProjectNeedsInitialization(w.store)
+}
+
+func (w *AppWorkspace) MarkProjectInitialized() error {
+	return config.MarkProjectInitialized(w.store)
+}
+
+func (w *AppWorkspace) InitializePrompt() (string, error) {
+	return agent.InitializePrompt(w.store)
+}
+
+// -- MCP operations --
+
+func (w *AppWorkspace) MCPGetStates() map[string]mcptools.ClientInfo {
+	return mcptools.GetStates()
+}
+
+func (w *AppWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
+	mcptools.RefreshPrompts(ctx, name)
+}
+
+func (w *AppWorkspace) MCPRefreshResources(ctx context.Context, name string) {
+	mcptools.RefreshResources(ctx, name)
+}
+
+func (w *AppWorkspace) RefreshMCPTools(ctx context.Context, name string) {
+	mcptools.RefreshTools(ctx, w.store, name)
+}
+
+func (w *AppWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
+	contents, err := mcptools.ReadResource(ctx, w.store, name, uri)
+	if err != nil {
+		return nil, err
+	}
+	result := make([]MCPResourceContents, len(contents))
+	for i, c := range contents {
+		result[i] = MCPResourceContents{
+			URI:      c.URI,
+			MIMEType: c.MIMEType,
+			Text:     c.Text,
+			Blob:     c.Blob,
+		}
+	}
+	return result, nil
+}
+
+func (w *AppWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
+	return commands.GetMCPPrompt(w.store, clientID, promptID, args)
+}
+
+func (w *AppWorkspace) EnableDockerMCP(ctx context.Context) error {
+	mcpConfig, err := w.store.PrepareDockerMCPConfig()
+	if err != nil {
+		return err
+	}
+
+	if err := mcptools.InitializeSingle(ctx, config.DockerMCPName, w.store); err != nil {
+		disableErr := mcptools.DisableSingle(w.store, config.DockerMCPName)
+		delete(w.store.Config().MCP, config.DockerMCPName)
+		return fmt.Errorf("failed to start docker MCP: %w", errors.Join(err, disableErr))
+	}
+
+	if err := w.store.PersistDockerMCPConfig(mcpConfig); err != nil {
+		disableErr := mcptools.DisableSingle(w.store, config.DockerMCPName)
+		delete(w.store.Config().MCP, config.DockerMCPName)
+		return fmt.Errorf("docker MCP started but failed to persist configuration: %w", errors.Join(err, disableErr))
+	}
+
+	return nil
+}
+
+func (w *AppWorkspace) DisableDockerMCP() error {
+	if err := mcptools.DisableSingle(w.store, config.DockerMCPName); err != nil {
+		return fmt.Errorf("failed to disable docker MCP: %w", err)
+	}
+	return w.store.DisableDockerMCP()
+}
+
+// -- Lifecycle --
+
+func (w *AppWorkspace) Subscribe(program *tea.Program) {
+	w.app.Subscribe(program)
+}
+
+func (w *AppWorkspace) Shutdown() {
+	w.app.Shutdown()
+}
+
+// App returns the underlying app.App instance.
+func (w *AppWorkspace) App() *app.App {
+	return w.app
+}
+
+// Store returns the underlying config store.
+func (w *AppWorkspace) Store() *config.ConfigStore {
+	return w.store
+}
+
+// Compile-time check that AppWorkspace implements Workspace.
+var _ Workspace = (*AppWorkspace)(nil)

internal/workspace/client_workspace.go 🔗

@@ -0,0 +1,773 @@
+package workspace
+
+import (
+	"context"
+	"fmt"
+	"log/slog"
+	"strings"
+	"sync"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/agent/notify"
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/client"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/log"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/oauth"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/proto"
+	"github.com/charmbracelet/crush/internal/pubsub"
+	"github.com/charmbracelet/crush/internal/session"
+	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+)
+
+// ClientWorkspace implements the Workspace interface by delegating all
+// operations to a remote server via the client SDK. It caches the
+// proto.Workspace returned at creation time and refreshes it after
+// config-mutating operations.
+type ClientWorkspace struct {
+	client *client.Client
+
+	mu sync.RWMutex
+	ws proto.Workspace
+}
+
+// NewClientWorkspace creates a new ClientWorkspace that proxies all
+// operations through the given client SDK. The ws parameter is the
+// proto.Workspace snapshot returned by the server at creation time.
+func NewClientWorkspace(c *client.Client, ws proto.Workspace) *ClientWorkspace {
+	if ws.Config != nil {
+		ws.Config.SetupAgents()
+	}
+	return &ClientWorkspace{
+		client: c,
+		ws:     ws,
+	}
+}
+
+// refreshWorkspace re-fetches the workspace from the server, updating
+// the cached snapshot. Called after config-mutating operations.
+func (w *ClientWorkspace) refreshWorkspace() {
+	updated, err := w.client.GetWorkspace(context.Background(), w.ws.ID)
+	if err != nil {
+		slog.Error("Failed to refresh workspace", "error", err)
+		return
+	}
+	if updated.Config != nil {
+		updated.Config.SetupAgents()
+	}
+	w.mu.Lock()
+	w.ws = *updated
+	w.mu.Unlock()
+}
+
+// cached returns a snapshot of the cached workspace.
+func (w *ClientWorkspace) cached() proto.Workspace {
+	w.mu.RLock()
+	defer w.mu.RUnlock()
+	return w.ws
+}
+
+// workspaceID returns the cached workspace ID.
+func (w *ClientWorkspace) workspaceID() string {
+	return w.cached().ID
+}
+
+// -- Sessions --
+
+func (w *ClientWorkspace) CreateSession(ctx context.Context, title string) (session.Session, error) {
+	sess, err := w.client.CreateSession(ctx, w.workspaceID(), title)
+	if err != nil {
+		return session.Session{}, err
+	}
+	return protoToSession(*sess), nil
+}
+
+func (w *ClientWorkspace) GetSession(ctx context.Context, sessionID string) (session.Session, error) {
+	sess, err := w.client.GetSession(ctx, w.workspaceID(), sessionID)
+	if err != nil {
+		return session.Session{}, err
+	}
+	return protoToSession(*sess), nil
+}
+
+func (w *ClientWorkspace) ListSessions(ctx context.Context) ([]session.Session, error) {
+	protoSessions, err := w.client.ListSessions(ctx, w.workspaceID())
+	if err != nil {
+		return nil, err
+	}
+	sessions := make([]session.Session, len(protoSessions))
+	for i, s := range protoSessions {
+		sessions[i] = protoToSession(s)
+	}
+	return sessions, nil
+}
+
+func (w *ClientWorkspace) SaveSession(ctx context.Context, sess session.Session) (session.Session, error) {
+	saved, err := w.client.SaveSession(ctx, w.workspaceID(), sessionToProto(sess))
+	if err != nil {
+		return session.Session{}, err
+	}
+	return protoToSession(*saved), nil
+}
+
+func (w *ClientWorkspace) DeleteSession(ctx context.Context, sessionID string) error {
+	return w.client.DeleteSession(ctx, w.workspaceID(), sessionID)
+}
+
+func (w *ClientWorkspace) CreateAgentToolSessionID(messageID, toolCallID string) string {
+	return fmt.Sprintf("%s$$%s", messageID, toolCallID)
+}
+
+func (w *ClientWorkspace) ParseAgentToolSessionID(sessionID string) (string, string, bool) {
+	parts := strings.Split(sessionID, "$$")
+	if len(parts) != 2 {
+		return "", "", false
+	}
+	return parts[0], parts[1], true
+}
+
+// -- Messages --
+
+func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
+	msgs, err := w.client.ListMessages(ctx, w.workspaceID(), sessionID)
+	if err != nil {
+		return nil, err
+	}
+	return protoToMessages(msgs), nil
+}
+
+func (w *ClientWorkspace) ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
+	msgs, err := w.client.ListUserMessages(ctx, w.workspaceID(), sessionID)
+	if err != nil {
+		return nil, err
+	}
+	return protoToMessages(msgs), nil
+}
+
+func (w *ClientWorkspace) ListAllUserMessages(ctx context.Context) ([]message.Message, error) {
+	msgs, err := w.client.ListAllUserMessages(ctx, w.workspaceID())
+	if err != nil {
+		return nil, err
+	}
+	return protoToMessages(msgs), nil
+}
+
+// -- Agent --
+
+func (w *ClientWorkspace) AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error {
+	return w.client.SendMessage(ctx, w.workspaceID(), sessionID, prompt, attachments...)
+}
+
+func (w *ClientWorkspace) AgentCancel(sessionID string) {
+	_ = w.client.CancelAgentSession(context.Background(), w.workspaceID(), sessionID)
+}
+
+func (w *ClientWorkspace) AgentIsBusy() bool {
+	info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
+	if err != nil {
+		return false
+	}
+	return info.IsBusy
+}
+
+func (w *ClientWorkspace) AgentIsSessionBusy(sessionID string) bool {
+	info, err := w.client.GetAgentSessionInfo(context.Background(), w.workspaceID(), sessionID)
+	if err != nil {
+		return false
+	}
+	return info.IsBusy
+}
+
+func (w *ClientWorkspace) AgentModel() AgentModel {
+	info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
+	if err != nil {
+		return AgentModel{}
+	}
+	return AgentModel{
+		CatwalkCfg: info.Model,
+		ModelCfg:   info.ModelCfg,
+	}
+}
+
+func (w *ClientWorkspace) AgentIsReady() bool {
+	info, err := w.client.GetAgentInfo(context.Background(), w.workspaceID())
+	if err != nil {
+		return false
+	}
+	return info.IsReady
+}
+
+func (w *ClientWorkspace) AgentQueuedPrompts(sessionID string) int {
+	count, err := w.client.GetAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
+	if err != nil {
+		return 0
+	}
+	return count
+}
+
+func (w *ClientWorkspace) AgentQueuedPromptsList(sessionID string) []string {
+	prompts, err := w.client.GetAgentSessionQueuedPromptsList(context.Background(), w.workspaceID(), sessionID)
+	if err != nil {
+		return nil
+	}
+	return prompts
+}
+
+func (w *ClientWorkspace) AgentClearQueue(sessionID string) {
+	_ = w.client.ClearAgentSessionQueuedPrompts(context.Background(), w.workspaceID(), sessionID)
+}
+
+func (w *ClientWorkspace) AgentSummarize(ctx context.Context, sessionID string) error {
+	return w.client.AgentSummarizeSession(ctx, w.workspaceID(), sessionID)
+}
+
+func (w *ClientWorkspace) UpdateAgentModel(ctx context.Context) error {
+	return w.client.UpdateAgent(ctx, w.workspaceID())
+}
+
+func (w *ClientWorkspace) InitCoderAgent(ctx context.Context) error {
+	return w.client.InitiateAgentProcessing(ctx, w.workspaceID())
+}
+
+func (w *ClientWorkspace) GetDefaultSmallModel(providerID string) config.SelectedModel {
+	model, err := w.client.GetDefaultSmallModel(context.Background(), w.workspaceID(), providerID)
+	if err != nil {
+		return config.SelectedModel{}
+	}
+	return *model
+}
+
+// -- Permissions --
+
+func (w *ClientWorkspace) PermissionGrant(perm permission.PermissionRequest) {
+	_ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
+		Permission: proto.PermissionRequest{
+			ID:          perm.ID,
+			SessionID:   perm.SessionID,
+			ToolCallID:  perm.ToolCallID,
+			ToolName:    perm.ToolName,
+			Description: perm.Description,
+			Action:      perm.Action,
+			Path:        perm.Path,
+			Params:      perm.Params,
+		},
+		Action: proto.PermissionAllowForSession,
+	})
+}
+
+func (w *ClientWorkspace) PermissionGrantPersistent(perm permission.PermissionRequest) {
+	_ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
+		Permission: proto.PermissionRequest{
+			ID:          perm.ID,
+			SessionID:   perm.SessionID,
+			ToolCallID:  perm.ToolCallID,
+			ToolName:    perm.ToolName,
+			Description: perm.Description,
+			Action:      perm.Action,
+			Path:        perm.Path,
+			Params:      perm.Params,
+		},
+		Action: proto.PermissionAllow,
+	})
+}
+
+func (w *ClientWorkspace) PermissionDeny(perm permission.PermissionRequest) {
+	_ = w.client.GrantPermission(context.Background(), w.workspaceID(), proto.PermissionGrant{
+		Permission: proto.PermissionRequest{
+			ID:          perm.ID,
+			SessionID:   perm.SessionID,
+			ToolCallID:  perm.ToolCallID,
+			ToolName:    perm.ToolName,
+			Description: perm.Description,
+			Action:      perm.Action,
+			Path:        perm.Path,
+			Params:      perm.Params,
+		},
+		Action: proto.PermissionDeny,
+	})
+}
+
+func (w *ClientWorkspace) PermissionSkipRequests() bool {
+	skip, err := w.client.GetPermissionsSkipRequests(context.Background(), w.workspaceID())
+	if err != nil {
+		return false
+	}
+	return skip
+}
+
+func (w *ClientWorkspace) PermissionSetSkipRequests(skip bool) {
+	_ = w.client.SetPermissionsSkipRequests(context.Background(), w.workspaceID(), skip)
+}
+
+// -- FileTracker --
+
+func (w *ClientWorkspace) FileTrackerRecordRead(ctx context.Context, sessionID, path string) {
+	_ = w.client.FileTrackerRecordRead(ctx, w.workspaceID(), sessionID, path)
+}
+
+func (w *ClientWorkspace) FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time {
+	t, err := w.client.FileTrackerLastReadTime(ctx, w.workspaceID(), sessionID, path)
+	if err != nil {
+		return time.Time{}
+	}
+	return t
+}
+
+func (w *ClientWorkspace) FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error) {
+	return w.client.FileTrackerListReadFiles(ctx, w.workspaceID(), sessionID)
+}
+
+// -- History --
+
+func (w *ClientWorkspace) ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error) {
+	files, err := w.client.ListSessionHistoryFiles(ctx, w.workspaceID(), sessionID)
+	if err != nil {
+		return nil, err
+	}
+	return protoToFiles(files), nil
+}
+
+// -- LSP --
+
+func (w *ClientWorkspace) LSPStart(ctx context.Context, path string) {
+	_ = w.client.LSPStart(ctx, w.workspaceID(), path)
+}
+
+func (w *ClientWorkspace) LSPStopAll(ctx context.Context) {
+	_ = w.client.LSPStopAll(ctx, w.workspaceID())
+}
+
+func (w *ClientWorkspace) LSPGetStates() map[string]LSPClientInfo {
+	states, err := w.client.GetLSPs(context.Background(), w.workspaceID())
+	if err != nil {
+		return nil
+	}
+	result := make(map[string]LSPClientInfo, len(states))
+	for k, v := range states {
+		result[k] = LSPClientInfo{
+			Name:            v.Name,
+			State:           v.State,
+			Error:           v.Error,
+			DiagnosticCount: v.DiagnosticCount,
+			ConnectedAt:     v.ConnectedAt,
+		}
+	}
+	return result
+}
+
+func (w *ClientWorkspace) LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts {
+	diags, err := w.client.GetLSPDiagnostics(context.Background(), w.workspaceID(), name)
+	if err != nil {
+		return lsp.DiagnosticCounts{}
+	}
+	var counts lsp.DiagnosticCounts
+	for _, fileDiags := range diags {
+		for _, d := range fileDiags {
+			switch d.Severity {
+			case protocol.SeverityError:
+				counts.Error++
+			case protocol.SeverityWarning:
+				counts.Warning++
+			case protocol.SeverityInformation:
+				counts.Information++
+			case protocol.SeverityHint:
+				counts.Hint++
+			}
+		}
+	}
+	return counts
+}
+
+// -- Config (read-only) --
+
+func (w *ClientWorkspace) Config() *config.Config {
+	return w.cached().Config
+}
+
+func (w *ClientWorkspace) WorkingDir() string {
+	return w.cached().Path
+}
+
+func (w *ClientWorkspace) Resolver() config.VariableResolver {
+	return config.IdentityResolver()
+}
+
+// -- Config mutations --
+
+func (w *ClientWorkspace) UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error {
+	err := w.client.UpdatePreferredModel(context.Background(), w.workspaceID(), scope, modelType, model)
+	if err == nil {
+		w.refreshWorkspace()
+	}
+	return err
+}
+
+func (w *ClientWorkspace) SetCompactMode(scope config.Scope, enabled bool) error {
+	err := w.client.SetCompactMode(context.Background(), w.workspaceID(), scope, enabled)
+	if err == nil {
+		w.refreshWorkspace()
+	}
+	return err
+}
+
+func (w *ClientWorkspace) SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error {
+	err := w.client.SetProviderAPIKey(context.Background(), w.workspaceID(), scope, providerID, apiKey)
+	if err == nil {
+		w.refreshWorkspace()
+	}
+	return err
+}
+
+func (w *ClientWorkspace) SetConfigField(scope config.Scope, key string, value any) error {
+	err := w.client.SetConfigField(context.Background(), w.workspaceID(), scope, key, value)
+	if err == nil {
+		w.refreshWorkspace()
+	}
+	return err
+}
+
+func (w *ClientWorkspace) RemoveConfigField(scope config.Scope, key string) error {
+	err := w.client.RemoveConfigField(context.Background(), w.workspaceID(), scope, key)
+	if err == nil {
+		w.refreshWorkspace()
+	}
+	return err
+}
+
+func (w *ClientWorkspace) ImportCopilot() (*oauth.Token, bool) {
+	token, ok, err := w.client.ImportCopilot(context.Background(), w.workspaceID())
+	if err != nil {
+		return nil, false
+	}
+	if ok {
+		w.refreshWorkspace()
+	}
+	return token, ok
+}
+
+func (w *ClientWorkspace) RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error {
+	err := w.client.RefreshOAuthToken(ctx, w.workspaceID(), scope, providerID)
+	if err == nil {
+		w.refreshWorkspace()
+	}
+	return err
+}
+
+// -- Project lifecycle --
+
+func (w *ClientWorkspace) ProjectNeedsInitialization() (bool, error) {
+	return w.client.ProjectNeedsInitialization(context.Background(), w.workspaceID())
+}
+
+func (w *ClientWorkspace) MarkProjectInitialized() error {
+	return w.client.MarkProjectInitialized(context.Background(), w.workspaceID())
+}
+
+func (w *ClientWorkspace) InitializePrompt() (string, error) {
+	return w.client.GetInitializePrompt(context.Background(), w.workspaceID())
+}
+
+// -- MCP operations --
+
+func (w *ClientWorkspace) MCPGetStates() map[string]mcp.ClientInfo {
+	states, err := w.client.MCPGetStates(context.Background(), w.workspaceID())
+	if err != nil {
+		return nil
+	}
+	result := make(map[string]mcp.ClientInfo, len(states))
+	for k, v := range states {
+		result[k] = mcp.ClientInfo{
+			Name:  v.Name,
+			State: mcp.State(v.State),
+			Error: v.Error,
+			Counts: mcp.Counts{
+				Tools:     v.ToolCount,
+				Prompts:   v.PromptCount,
+				Resources: v.ResourceCount,
+			},
+			ConnectedAt: v.ConnectedAt,
+		}
+	}
+	return result
+}
+
+func (w *ClientWorkspace) MCPRefreshPrompts(ctx context.Context, name string) {
+	_ = w.client.MCPRefreshPrompts(ctx, w.workspaceID(), name)
+}
+
+func (w *ClientWorkspace) MCPRefreshResources(ctx context.Context, name string) {
+	_ = w.client.MCPRefreshResources(ctx, w.workspaceID(), name)
+}
+
+func (w *ClientWorkspace) RefreshMCPTools(ctx context.Context, name string) {
+	_ = w.client.RefreshMCPTools(ctx, w.workspaceID(), name)
+}
+
+func (w *ClientWorkspace) ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error) {
+	contents, err := w.client.ReadMCPResource(ctx, w.workspaceID(), name, uri)
+	if err != nil {
+		return nil, err
+	}
+	result := make([]MCPResourceContents, len(contents))
+	for i, c := range contents {
+		result[i] = MCPResourceContents{
+			URI:      c.URI,
+			MIMEType: c.MIMEType,
+			Text:     c.Text,
+			Blob:     c.Blob,
+		}
+	}
+	return result, nil
+}
+
+func (w *ClientWorkspace) GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error) {
+	return w.client.GetMCPPrompt(context.Background(), w.workspaceID(), clientID, promptID, args)
+}
+
+func (w *ClientWorkspace) EnableDockerMCP(ctx context.Context) error {
+	return w.client.EnableDockerMCP(ctx, w.workspaceID())
+}
+
+func (w *ClientWorkspace) DisableDockerMCP() error {
+	return w.client.DisableDockerMCP(context.Background(), w.workspaceID())
+}
+
+// -- Lifecycle --
+
+func (w *ClientWorkspace) Subscribe(program *tea.Program) {
+	defer log.RecoverPanic("ClientWorkspace.Subscribe", func() {
+		slog.Info("TUI subscription panic: attempting graceful shutdown")
+		program.Quit()
+	})
+
+	evc, err := w.client.SubscribeEvents(context.Background(), w.workspaceID())
+	if err != nil {
+		slog.Error("Failed to subscribe to events", "error", err)
+		return
+	}
+
+	for ev := range evc {
+		translated := translateEvent(ev)
+		if translated != nil {
+			program.Send(translated)
+		}
+	}
+}
+
+func (w *ClientWorkspace) Shutdown() {
+	_ = w.client.DeleteWorkspace(context.Background(), w.workspaceID())
+}
+
+// translateEvent converts proto-typed SSE events into the domain types
+// that the TUI's Update() method expects.
+func translateEvent(ev any) tea.Msg {
+	switch e := ev.(type) {
+	case pubsub.Event[proto.LSPEvent]:
+		return pubsub.Event[LSPEvent]{
+			Type: e.Type,
+			Payload: LSPEvent{
+				Type:            LSPEventType(e.Payload.Type),
+				Name:            e.Payload.Name,
+				State:           e.Payload.State,
+				Error:           e.Payload.Error,
+				DiagnosticCount: e.Payload.DiagnosticCount,
+			},
+		}
+	case pubsub.Event[proto.MCPEvent]:
+		return pubsub.Event[mcp.Event]{
+			Type: e.Type,
+			Payload: mcp.Event{
+				Type:  protoToMCPEventType(e.Payload.Type),
+				Name:  e.Payload.Name,
+				State: mcp.State(e.Payload.State),
+				Error: e.Payload.Error,
+				Counts: mcp.Counts{
+					Tools:     e.Payload.ToolCount,
+					Prompts:   e.Payload.PromptCount,
+					Resources: e.Payload.ResourceCount,
+				},
+			},
+		}
+	case pubsub.Event[proto.PermissionRequest]:
+		return pubsub.Event[permission.PermissionRequest]{
+			Type: e.Type,
+			Payload: permission.PermissionRequest{
+				ID:          e.Payload.ID,
+				SessionID:   e.Payload.SessionID,
+				ToolCallID:  e.Payload.ToolCallID,
+				ToolName:    e.Payload.ToolName,
+				Description: e.Payload.Description,
+				Action:      e.Payload.Action,
+				Path:        e.Payload.Path,
+				Params:      e.Payload.Params,
+			},
+		}
+	case pubsub.Event[proto.PermissionNotification]:
+		return pubsub.Event[permission.PermissionNotification]{
+			Type: e.Type,
+			Payload: permission.PermissionNotification{
+				ToolCallID: e.Payload.ToolCallID,
+				Granted:    e.Payload.Granted,
+				Denied:     e.Payload.Denied,
+			},
+		}
+	case pubsub.Event[proto.Message]:
+		return pubsub.Event[message.Message]{
+			Type:    e.Type,
+			Payload: protoToMessage(e.Payload),
+		}
+	case pubsub.Event[proto.Session]:
+		return pubsub.Event[session.Session]{
+			Type:    e.Type,
+			Payload: protoToSession(e.Payload),
+		}
+	case pubsub.Event[proto.File]:
+		return pubsub.Event[history.File]{
+			Type:    e.Type,
+			Payload: protoToFile(e.Payload),
+		}
+	case pubsub.Event[proto.AgentEvent]:
+		return pubsub.Event[notify.Notification]{
+			Type: e.Type,
+			Payload: notify.Notification{
+				SessionID:    e.Payload.SessionID,
+				SessionTitle: e.Payload.SessionTitle,
+				Type:         notify.Type(e.Payload.Type),
+			},
+		}
+	default:
+		slog.Warn("Unknown event type in translateEvent", "type", fmt.Sprintf("%T", ev))
+		return nil
+	}
+}
+
+func protoToMCPEventType(t proto.MCPEventType) mcp.EventType {
+	switch t {
+	case proto.MCPEventStateChanged:
+		return mcp.EventStateChanged
+	case proto.MCPEventToolsListChanged:
+		return mcp.EventToolsListChanged
+	case proto.MCPEventPromptsListChanged:
+		return mcp.EventPromptsListChanged
+	case proto.MCPEventResourcesListChanged:
+		return mcp.EventResourcesListChanged
+	default:
+		return mcp.EventStateChanged
+	}
+}
+
+func protoToSession(s proto.Session) session.Session {
+	return session.Session{
+		ID:               s.ID,
+		ParentSessionID:  s.ParentSessionID,
+		Title:            s.Title,
+		SummaryMessageID: s.SummaryMessageID,
+		MessageCount:     s.MessageCount,
+		PromptTokens:     s.PromptTokens,
+		CompletionTokens: s.CompletionTokens,
+		Cost:             s.Cost,
+		CreatedAt:        s.CreatedAt,
+		UpdatedAt:        s.UpdatedAt,
+	}
+}
+
+func protoToFile(f proto.File) history.File {
+	return history.File{
+		ID:        f.ID,
+		SessionID: f.SessionID,
+		Path:      f.Path,
+		Content:   f.Content,
+		Version:   f.Version,
+		CreatedAt: f.CreatedAt,
+		UpdatedAt: f.UpdatedAt,
+	}
+}
+
+func protoToMessage(m proto.Message) message.Message {
+	msg := message.Message{
+		ID:        m.ID,
+		SessionID: m.SessionID,
+		Role:      message.MessageRole(m.Role),
+		Model:     m.Model,
+		Provider:  m.Provider,
+		CreatedAt: m.CreatedAt,
+		UpdatedAt: m.UpdatedAt,
+	}
+
+	for _, p := range m.Parts {
+		switch v := p.(type) {
+		case proto.TextContent:
+			msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
+		case proto.ReasoningContent:
+			msg.Parts = append(msg.Parts, message.ReasoningContent{
+				Thinking:   v.Thinking,
+				Signature:  v.Signature,
+				StartedAt:  v.StartedAt,
+				FinishedAt: v.FinishedAt,
+			})
+		case proto.ToolCall:
+			msg.Parts = append(msg.Parts, message.ToolCall{
+				ID:       v.ID,
+				Name:     v.Name,
+				Input:    v.Input,
+				Finished: v.Finished,
+			})
+		case proto.ToolResult:
+			msg.Parts = append(msg.Parts, message.ToolResult{
+				ToolCallID: v.ToolCallID,
+				Name:       v.Name,
+				Content:    v.Content,
+				IsError:    v.IsError,
+			})
+		case proto.Finish:
+			msg.Parts = append(msg.Parts, message.Finish{
+				Reason:  message.FinishReason(v.Reason),
+				Time:    v.Time,
+				Message: v.Message,
+				Details: v.Details,
+			})
+		case proto.ImageURLContent:
+			msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
+		case proto.BinaryContent:
+			msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
+		}
+	}
+
+	return msg
+}
+
+func protoToMessages(msgs []proto.Message) []message.Message {
+	out := make([]message.Message, len(msgs))
+	for i, m := range msgs {
+		out[i] = protoToMessage(m)
+	}
+	return out
+}
+
+func protoToFiles(files []proto.File) []history.File {
+	out := make([]history.File, len(files))
+	for i, f := range files {
+		out[i] = protoToFile(f)
+	}
+	return out
+}
+
+func sessionToProto(s session.Session) proto.Session {
+	return proto.Session{
+		ID:               s.ID,
+		ParentSessionID:  s.ParentSessionID,
+		Title:            s.Title,
+		SummaryMessageID: s.SummaryMessageID,
+		MessageCount:     s.MessageCount,
+		PromptTokens:     s.PromptTokens,
+		CompletionTokens: s.CompletionTokens,
+		Cost:             s.Cost,
+		CreatedAt:        s.CreatedAt,
+		UpdatedAt:        s.UpdatedAt,
+	}
+}

internal/workspace/workspace.go 🔗

@@ -0,0 +1,152 @@
+// Package workspace defines the Workspace interface used by all
+// frontends (TUI, CLI) to interact with a running workspace. Two
+// implementations exist: one wrapping a local app.App instance and one
+// wrapping the HTTP client SDK.
+package workspace
+
+import (
+	"context"
+	"time"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/catwalk/pkg/catwalk"
+	mcptools "github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/history"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/oauth"
+	"github.com/charmbracelet/crush/internal/permission"
+	"github.com/charmbracelet/crush/internal/session"
+)
+
+// LSPClientInfo holds information about an LSP client's state. This is
+// the frontend-facing type; implementations translate from the
+// underlying app or proto representation.
+type LSPClientInfo struct {
+	Name            string
+	State           lsp.ServerState
+	Error           error
+	DiagnosticCount int
+	ConnectedAt     time.Time
+}
+
+// LSPEventType represents the type of LSP event.
+type LSPEventType string
+
+const (
+	LSPEventStateChanged       LSPEventType = "state_changed"
+	LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed"
+)
+
+// LSPEvent represents an LSP event forwarded to the TUI.
+type LSPEvent struct {
+	Type            LSPEventType
+	Name            string
+	State           lsp.ServerState
+	Error           error
+	DiagnosticCount int
+}
+
+// AgentModel holds the model information exposed to the UI.
+type AgentModel struct {
+	CatwalkCfg catwalk.Model
+	ModelCfg   config.SelectedModel
+}
+
+// Workspace is the main abstraction consumed by the TUI and CLI. It
+// groups every operation a frontend needs to perform against a running
+// workspace, regardless of whether the workspace is in-process or
+// remote.
+type Workspace interface {
+	// Sessions
+	CreateSession(ctx context.Context, title string) (session.Session, error)
+	GetSession(ctx context.Context, sessionID string) (session.Session, error)
+	ListSessions(ctx context.Context) ([]session.Session, error)
+	SaveSession(ctx context.Context, sess session.Session) (session.Session, error)
+	DeleteSession(ctx context.Context, sessionID string) error
+	CreateAgentToolSessionID(messageID, toolCallID string) string
+	ParseAgentToolSessionID(sessionID string) (messageID string, toolCallID string, ok bool)
+
+	// Messages
+	ListMessages(ctx context.Context, sessionID string) ([]message.Message, error)
+	ListUserMessages(ctx context.Context, sessionID string) ([]message.Message, error)
+	ListAllUserMessages(ctx context.Context) ([]message.Message, error)
+
+	// Agent
+	AgentRun(ctx context.Context, sessionID, prompt string, attachments ...message.Attachment) error
+	AgentCancel(sessionID string)
+	AgentIsBusy() bool
+	AgentIsSessionBusy(sessionID string) bool
+	AgentModel() AgentModel
+	AgentIsReady() bool
+	AgentQueuedPrompts(sessionID string) int
+	AgentQueuedPromptsList(sessionID string) []string
+	AgentClearQueue(sessionID string)
+	AgentSummarize(ctx context.Context, sessionID string) error
+	UpdateAgentModel(ctx context.Context) error
+	InitCoderAgent(ctx context.Context) error
+	GetDefaultSmallModel(providerID string) config.SelectedModel
+
+	// Permissions
+	PermissionGrant(perm permission.PermissionRequest)
+	PermissionGrantPersistent(perm permission.PermissionRequest)
+	PermissionDeny(perm permission.PermissionRequest)
+	PermissionSkipRequests() bool
+	PermissionSetSkipRequests(skip bool)
+
+	// FileTracker
+	FileTrackerRecordRead(ctx context.Context, sessionID, path string)
+	FileTrackerLastReadTime(ctx context.Context, sessionID, path string) time.Time
+	FileTrackerListReadFiles(ctx context.Context, sessionID string) ([]string, error)
+
+	// History
+	ListSessionHistory(ctx context.Context, sessionID string) ([]history.File, error)
+
+	// LSP
+	LSPStart(ctx context.Context, path string)
+	LSPStopAll(ctx context.Context)
+	LSPGetStates() map[string]LSPClientInfo
+	LSPGetDiagnosticCounts(name string) lsp.DiagnosticCounts
+
+	// Config (read-only data)
+	Config() *config.Config
+	WorkingDir() string
+	Resolver() config.VariableResolver
+
+	// Config mutations (proxied to server in client mode)
+	UpdatePreferredModel(scope config.Scope, modelType config.SelectedModelType, model config.SelectedModel) error
+	SetCompactMode(scope config.Scope, enabled bool) error
+	SetProviderAPIKey(scope config.Scope, providerID string, apiKey any) error
+	SetConfigField(scope config.Scope, key string, value any) error
+	RemoveConfigField(scope config.Scope, key string) error
+	ImportCopilot() (*oauth.Token, bool)
+	RefreshOAuthToken(ctx context.Context, scope config.Scope, providerID string) error
+
+	// Project lifecycle
+	ProjectNeedsInitialization() (bool, error)
+	MarkProjectInitialized() error
+	InitializePrompt() (string, error)
+
+	// MCP operations (server-side in client mode)
+	MCPGetStates() map[string]mcptools.ClientInfo
+	MCPRefreshPrompts(ctx context.Context, name string)
+	MCPRefreshResources(ctx context.Context, name string)
+	RefreshMCPTools(ctx context.Context, name string)
+	ReadMCPResource(ctx context.Context, name, uri string) ([]MCPResourceContents, error)
+	GetMCPPrompt(clientID, promptID string, args map[string]string) (string, error)
+	EnableDockerMCP(ctx context.Context) error
+	DisableDockerMCP() error
+
+	// Events
+	Subscribe(program *tea.Program)
+	Shutdown()
+}
+
+// MCPResourceContents holds the contents of an MCP resource.
+type MCPResourceContents struct {
+	URI      string `json:"uri"`
+	MIMEType string `json:"mime_type,omitempty"`
+	Text     string `json:"text,omitempty"`
+	Blob     []byte `json:"blob,omitempty"`
+}

main.go 🔗

@@ -1,3 +1,13 @@
+// Package main is the entry point for the Crush CLI.
+//
+//	@title			Crush API
+//	@version		1.0
+//	@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.
+//	@contact.name	Charm
+//	@contact.url	https://charm.sh
+//	@license.name	MIT
+//	@license.url	https://github.com/charmbracelet/crush/blob/main/LICENSE
+//	@BasePath		/v1
 package main
 
 import (