on main help sign in
feat(server): initial implementation of Crush RPC server/client
commit0f2e2f0526cdb2d46596b1cad927575e57f0402f authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 10 Mar 2026, 18:00 (7 months ago) parent45c25b3
34 files changed+3421−11
go.mod+1 go.sum+2 internal/app/app.go+5 internal/client/client.go+192 internal/client/dial_other.go+14 internal/client/dial_windows.go+15 internal/client/proto.go+490 internal/cmd/root.go+174 internal/cmd/root_other.go+16 internal/cmd/root_windows.go+18 internal/cmd/server.go+98 internal/cmd/server_other.go+13 internal/cmd/server_windows.go+10 internal/config/load.go+19 internal/proto/agent.go+74 internal/proto/history.go+12 internal/proto/mcp.go+78 internal/proto/message.go+653 internal/proto/permission.go+141 internal/proto/proto.go+123 internal/proto/server.go+6 internal/proto/session.go+15 internal/proto/tools.go+250 internal/proto/version.go+9 internal/pubsub/events.go+31−5 internal/server/logging.go+51 internal/server/net_other.go+10 internal/server/net_windows.go+24 internal/server/proto.go+656 internal/server/server.go+208 internal/ui/model/onboarding.go+4−1 internal/ui/model/ui.go+4−1 internal/ui/util/util.go−2 internal/version/version.go+5−2
go.mod+1
@@ -79,6 +79,7 @@ require (
7979 cloud.google.com/go/compute/metadata v0.9.0 // indirect
8080 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
8181 github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
82+ github.com/Microsoft/go-winio v0.6.2 // indirect
8283 github.com/andybalholm/cascadia v1.3.3 // indirect
8384 github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect
8485 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
go.sum+2
@@ -34,6 +34,8 @@ github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5
3434github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
3535github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
3636github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
37+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
38+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
3739github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
3840github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
3941github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
internal/app/app.go+5
@@ -143,6 +143,11 @@ func (app *App) Config() *config.Config {
143143 return app.config
144144}
145145
146+// Events returns the events channel for the application.
147+func (app *App) Events() <-chan tea.Msg {
148+ return app.events
149+}
150+
146151// RunNonInteractive runs the application in non-interactive mode with the
147152// given prompt, printing to stdout.
148153func (app *App) RunNonInteractive(ctx context.Context, output io.Writer, prompt, largeModel, smallModel string, hideSpinner bool) error {
internal/client/client.gonew file+192
@@ -0,0 +1,192 @@
1+package client
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net"
9+ "net/http"
10+ "net/url"
11+ stdpath "path"
12+ "path/filepath"
13+ "time"
14+
15+ "github.com/charmbracelet/crush/internal/config"
16+ "github.com/charmbracelet/crush/internal/proto"
17+ "github.com/charmbracelet/crush/internal/server"
18+)
19+
20+// DummyHost is used to satisfy the http.Client's requirement for a URL.
21+const DummyHost = "api.crush.localhost"
22+
23+// Client represents an RPC client connected to a Crush server.
24+type Client struct {
25+ h *http.Client
26+ path string
27+ network string
28+ addr string
29+}
30+
31+// DefaultClient creates a new [Client] connected to the default server address.
32+func DefaultClient(path string) (*Client, error) {
33+ host, err := server.ParseHostURL(server.DefaultHost())
34+ if err != nil {
35+ return nil, err
36+ }
37+ return NewClient(path, host.Scheme, host.Host)
38+}
39+
40+// NewClient creates a new [Client] connected to the server at the given
41+// network and address.
42+func NewClient(path, network, address string) (*Client, error) {
43+ c := new(Client)
44+ c.path = filepath.Clean(path)
45+ c.network = network
46+ c.addr = address
47+ p := &http.Protocols{}
48+ p.SetHTTP1(true)
49+ p.SetUnencryptedHTTP2(true)
50+ tr := http.DefaultTransport.(*http.Transport).Clone()
51+ tr.Protocols = p
52+ tr.DialContext = c.dialer
53+ if c.network == "npipe" || c.network == "unix" {
54+ tr.DisableCompression = true
55+ }
56+ c.h = &http.Client{
57+ Transport: tr,
58+ Timeout: 0,
59+ }
60+ return c, nil
61+}
62+
63+// Path returns the client's workspace filesystem path.
64+func (c *Client) Path() string {
65+ return c.path
66+}
67+
68+// GetGlobalConfig retrieves the server's configuration.
69+func (c *Client) GetGlobalConfig(ctx context.Context) (*config.Config, error) {
70+ var cfg config.Config
71+ rsp, err := c.get(ctx, "/config", nil, nil)
72+ if err != nil {
73+ return nil, err
74+ }
75+ defer rsp.Body.Close()
76+ if err := json.NewDecoder(rsp.Body).Decode(&cfg); err != nil {
77+ return nil, err
78+ }
79+ return &cfg, nil
80+}
81+
82+// Health checks the server's health status.
83+func (c *Client) Health(ctx context.Context) error {
84+ rsp, err := c.get(ctx, "/health", nil, nil)
85+ if err != nil {
86+ return err
87+ }
88+ defer rsp.Body.Close()
89+ if rsp.StatusCode != http.StatusOK {
90+ return fmt.Errorf("server health check failed: %s", rsp.Status)
91+ }
92+ return nil
93+}
94+
95+// VersionInfo retrieves the server's version information.
96+func (c *Client) VersionInfo(ctx context.Context) (*proto.VersionInfo, error) {
97+ var vi proto.VersionInfo
98+ rsp, err := c.get(ctx, "version", nil, nil)
99+ if err != nil {
100+ return nil, err
101+ }
102+ defer rsp.Body.Close()
103+ if err := json.NewDecoder(rsp.Body).Decode(&vi); err != nil {
104+ return nil, err
105+ }
106+ return &vi, nil
107+}
108+
109+// ShutdownServer sends a shutdown request to the server.
110+func (c *Client) ShutdownServer(ctx context.Context) error {
111+ rsp, err := c.post(ctx, "/control", nil, jsonBody(proto.ServerControl{
112+ Command: "shutdown",
113+ }), nil)
114+ if err != nil {
115+ return err
116+ }
117+ defer rsp.Body.Close()
118+ if rsp.StatusCode != http.StatusOK {
119+ return fmt.Errorf("server shutdown failed: %s", rsp.Status)
120+ }
121+ return nil
122+}
123+
124+func (c *Client) dialer(ctx context.Context, network, address string) (net.Conn, error) {
125+ d := net.Dialer{
126+ Timeout: 30 * time.Second,
127+ KeepAlive: 30 * time.Second,
128+ }
129+ switch c.network {
130+ case "npipe":
131+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
132+ defer cancel()
133+ return dialPipeContext(ctx, c.addr)
134+ case "unix":
135+ return d.DialContext(ctx, "unix", c.addr)
136+ default:
137+ return d.DialContext(ctx, network, address)
138+ }
139+}
140+
141+func (c *Client) get(ctx context.Context, path string, query url.Values, headers http.Header) (*http.Response, error) {
142+ return c.sendReq(ctx, http.MethodGet, path, query, nil, headers)
143+}
144+
145+func (c *Client) post(ctx context.Context, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
146+ return c.sendReq(ctx, http.MethodPost, path, query, body, headers)
147+}
148+
149+func (c *Client) delete(ctx context.Context, path string, query url.Values, headers http.Header) (*http.Response, error) {
150+ return c.sendReq(ctx, http.MethodDelete, path, query, nil, headers)
151+}
152+
153+func (c *Client) sendReq(ctx context.Context, method, path string, query url.Values, body io.Reader, headers http.Header) (*http.Response, error) {
154+ url := (&url.URL{
155+ Path: stdpath.Join("/v1", path),
156+ RawQuery: query.Encode(),
157+ }).String()
158+ req, err := c.buildReq(ctx, method, url, body, headers)
159+ if err != nil {
160+ return nil, err
161+ }
162+
163+ rsp, err := c.h.Do(req)
164+ if err != nil {
165+ return nil, err
166+ }
167+
168+ return rsp, nil
169+}
170+
171+func (c *Client) buildReq(ctx context.Context, method, url string, body io.Reader, headers http.Header) (*http.Request, error) {
172+ r, err := http.NewRequestWithContext(ctx, method, url, body)
173+ if err != nil {
174+ return nil, err
175+ }
176+
177+ for k, v := range headers {
178+ r.Header[http.CanonicalHeaderKey(k)] = v
179+ }
180+
181+ r.URL.Scheme = "http"
182+ r.URL.Host = c.addr
183+ if c.network == "npipe" || c.network == "unix" {
184+ r.Host = DummyHost
185+ }
186+
187+ if body != nil && r.Header.Get("Content-Type") == "" {
188+ r.Header.Set("Content-Type", "text/plain")
189+ }
190+
191+ return r, nil
192+}
internal/client/dial_other.gonew file+14
@@ -0,0 +1,14 @@
1+//go:build !windows
2+// +build !windows
3+
4+package client
5+
6+import (
7+ "context"
8+ "net"
9+ "syscall"
10+)
11+
12+func dialPipeContext(context.Context, string) (net.Conn, error) {
13+ return nil, syscall.EAFNOSUPPORT
14+}
internal/client/dial_windows.gonew file+15
@@ -0,0 +1,15 @@
1+//go:build windows
2+// +build windows
3+
4+package client
5+
6+import (
7+ "context"
8+ "net"
9+
10+ "github.com/Microsoft/go-winio"
11+)
12+
13+func dialPipeContext(ctx context.Context, address string) (net.Conn, error) {
14+ return winio.DialPipeContext(ctx, address)
15+}
internal/client/proto.gonew file+490
@@ -0,0 +1,490 @@
1+package client
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "log/slog"
12+ "net/http"
13+ "time"
14+
15+ "github.com/charmbracelet/crush/internal/app"
16+ "github.com/charmbracelet/crush/internal/config"
17+ "github.com/charmbracelet/crush/internal/history"
18+ "github.com/charmbracelet/crush/internal/message"
19+ "github.com/charmbracelet/crush/internal/proto"
20+ "github.com/charmbracelet/crush/internal/pubsub"
21+ "github.com/charmbracelet/crush/internal/session"
22+ "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
23+)
24+
25+// CreateWorkspace creates a new workspace on the server.
26+func (c *Client) CreateWorkspace(ctx context.Context, ws proto.Workspace) (*proto.Workspace, error) {
27+ rsp, err := c.post(ctx, "/workspaces", nil, jsonBody(ws), http.Header{"Content-Type": []string{"application/json"}})
28+ if err != nil {
29+ return nil, fmt.Errorf("failed to create workspace: %w", err)
30+ }
31+ defer rsp.Body.Close()
32+ if rsp.StatusCode != http.StatusOK {
33+ return nil, fmt.Errorf("failed to create workspace: status code %d", rsp.StatusCode)
34+ }
35+ var created proto.Workspace
36+ if err := json.NewDecoder(rsp.Body).Decode(&created); err != nil {
37+ return nil, fmt.Errorf("failed to decode workspace: %w", err)
38+ }
39+ return &created, nil
40+}
41+
42+// DeleteWorkspace deletes a workspace on the server.
43+func (c *Client) DeleteWorkspace(ctx context.Context, id string) error {
44+ rsp, err := c.delete(ctx, fmt.Sprintf("/workspaces/%s", id), nil, nil)
45+ if err != nil {
46+ return fmt.Errorf("failed to delete workspace: %w", err)
47+ }
48+ defer rsp.Body.Close()
49+ if rsp.StatusCode != http.StatusOK {
50+ return fmt.Errorf("failed to delete workspace: status code %d", rsp.StatusCode)
51+ }
52+ return nil
53+}
54+
55+// SubscribeEvents subscribes to server-sent events for a workspace.
56+func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, error) {
57+ events := make(chan any, 100)
58+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/events", id), nil, http.Header{
59+ "Accept": []string{"text/event-stream"},
60+ "Cache-Control": []string{"no-cache"},
61+ "Connection": []string{"keep-alive"},
62+ })
63+ if err != nil {
64+ return nil, fmt.Errorf("failed to subscribe to events: %w", err)
65+ }
66+
67+ if rsp.StatusCode != http.StatusOK {
68+ return nil, fmt.Errorf("failed to subscribe to events: status code %d", rsp.StatusCode)
69+ }
70+
71+ go func() {
72+ defer rsp.Body.Close()
73+
74+ scr := bufio.NewReader(rsp.Body)
75+ for {
76+ line, err := scr.ReadBytes('\n')
77+ if errors.Is(err, io.EOF) {
78+ break
79+ }
80+ if err != nil {
81+ slog.Error("Reading from events stream", "error", err)
82+ time.Sleep(time.Second * 2)
83+ continue
84+ }
85+ line = bytes.TrimSpace(line)
86+ if len(line) == 0 {
87+ continue
88+ }
89+
90+ data, ok := bytes.CutPrefix(line, []byte("data:"))
91+ if !ok {
92+ slog.Warn("Invalid event format", "line", string(line))
93+ continue
94+ }
95+
96+ data = bytes.TrimSpace(data)
97+
98+ var event pubsub.Event[any]
99+ if err := json.Unmarshal(data, &event); err != nil {
100+ slog.Error("Unmarshaling event", "error", err)
101+ continue
102+ }
103+
104+ type alias pubsub.Event[any]
105+ aux := &struct {
106+ Payload json.RawMessage `json:"payload"`
107+ *alias
108+ }{
109+ alias: (*alias)(&event),
110+ }
111+
112+ if err := json.Unmarshal(data, &aux); err != nil {
113+ slog.Error("Unmarshaling event payload", "error", err)
114+ continue
115+ }
116+
117+ var p pubsub.Payload
118+ if err := json.Unmarshal(aux.Payload, &p); err != nil {
119+ slog.Error("Unmarshaling event payload", "error", err)
120+ continue
121+ }
122+
123+ switch p.Type {
124+ case pubsub.PayloadTypeLSPEvent:
125+ var e pubsub.Event[proto.LSPEvent]
126+ _ = json.Unmarshal(data, &e)
127+ sendEvent(ctx, events, e)
128+ case pubsub.PayloadTypeMCPEvent:
129+ var e pubsub.Event[proto.MCPEvent]
130+ _ = json.Unmarshal(data, &e)
131+ sendEvent(ctx, events, e)
132+ case pubsub.PayloadTypePermissionRequest:
133+ var e pubsub.Event[proto.PermissionRequest]
134+ _ = json.Unmarshal(data, &e)
135+ sendEvent(ctx, events, e)
136+ case pubsub.PayloadTypePermissionNotification:
137+ var e pubsub.Event[proto.PermissionNotification]
138+ _ = json.Unmarshal(data, &e)
139+ sendEvent(ctx, events, e)
140+ case pubsub.PayloadTypeMessage:
141+ var e pubsub.Event[proto.Message]
142+ _ = json.Unmarshal(data, &e)
143+ sendEvent(ctx, events, e)
144+ case pubsub.PayloadTypeSession:
145+ var e pubsub.Event[proto.Session]
146+ _ = json.Unmarshal(data, &e)
147+ sendEvent(ctx, events, e)
148+ case pubsub.PayloadTypeFile:
149+ var e pubsub.Event[proto.File]
150+ _ = json.Unmarshal(data, &e)
151+ sendEvent(ctx, events, e)
152+ case pubsub.PayloadTypeAgentEvent:
153+ var e pubsub.Event[proto.AgentEvent]
154+ _ = json.Unmarshal(data, &e)
155+ sendEvent(ctx, events, e)
156+ default:
157+ slog.Warn("Unknown event type", "type", p.Type)
158+ continue
159+ }
160+ }
161+ }()
162+
163+ return events, nil
164+}
165+
166+func sendEvent(ctx context.Context, evc chan any, ev any) {
167+ slog.Info("Event received", "event", fmt.Sprintf("%T %+v", ev, ev))
168+ select {
169+ case evc <- ev:
170+ case <-ctx.Done():
171+ close(evc)
172+ return
173+ }
174+}
175+
176+// GetLSPDiagnostics retrieves LSP diagnostics for a specific LSP client.
177+func (c *Client) GetLSPDiagnostics(ctx context.Context, id string, lspName string) (map[protocol.DocumentURI][]protocol.Diagnostic, error) {
178+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/lsps/%s/diagnostics", id, lspName), nil, nil)
179+ if err != nil {
180+ return nil, fmt.Errorf("failed to get LSP diagnostics: %w", err)
181+ }
182+ defer rsp.Body.Close()
183+ if rsp.StatusCode != http.StatusOK {
184+ return nil, fmt.Errorf("failed to get LSP diagnostics: status code %d", rsp.StatusCode)
185+ }
186+ var diagnostics map[protocol.DocumentURI][]protocol.Diagnostic
187+ if err := json.NewDecoder(rsp.Body).Decode(&diagnostics); err != nil {
188+ return nil, fmt.Errorf("failed to decode LSP diagnostics: %w", err)
189+ }
190+ return diagnostics, nil
191+}
192+
193+// GetLSPs retrieves the LSP client states for a workspace.
194+func (c *Client) GetLSPs(ctx context.Context, id string) (map[string]app.LSPClientInfo, error) {
195+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/lsps", id), nil, nil)
196+ if err != nil {
197+ return nil, fmt.Errorf("failed to get LSPs: %w", err)
198+ }
199+ defer rsp.Body.Close()
200+ if rsp.StatusCode != http.StatusOK {
201+ return nil, fmt.Errorf("failed to get LSPs: status code %d", rsp.StatusCode)
202+ }
203+ var lsps map[string]app.LSPClientInfo
204+ if err := json.NewDecoder(rsp.Body).Decode(&lsps); err != nil {
205+ return nil, fmt.Errorf("failed to decode LSPs: %w", err)
206+ }
207+ return lsps, nil
208+}
209+
210+// GetAgentSessionQueuedPrompts retrieves the number of queued prompts for a
211+// session.
212+func (c *Client) GetAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) (int, error) {
213+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/queued", id, sessionID), nil, nil)
214+ if err != nil {
215+ return 0, fmt.Errorf("failed to get session agent queued prompts: %w", err)
216+ }
217+ defer rsp.Body.Close()
218+ if rsp.StatusCode != http.StatusOK {
219+ return 0, fmt.Errorf("failed to get session agent queued prompts: status code %d", rsp.StatusCode)
220+ }
221+ var count int
222+ if err := json.NewDecoder(rsp.Body).Decode(&count); err != nil {
223+ return 0, fmt.Errorf("failed to decode session agent queued prompts: %w", err)
224+ }
225+ return count, nil
226+}
227+
228+// ClearAgentSessionQueuedPrompts clears the queued prompts for a session.
229+func (c *Client) ClearAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) error {
230+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/clear", id, sessionID), nil, nil, nil)
231+ if err != nil {
232+ return fmt.Errorf("failed to clear session agent queued prompts: %w", err)
233+ }
234+ defer rsp.Body.Close()
235+ if rsp.StatusCode != http.StatusOK {
236+ return fmt.Errorf("failed to clear session agent queued prompts: status code %d", rsp.StatusCode)
237+ }
238+ return nil
239+}
240+
241+// GetAgentInfo retrieves the agent status for a workspace.
242+func (c *Client) GetAgentInfo(ctx context.Context, id string) (*proto.AgentInfo, error) {
243+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent", id), nil, nil)
244+ if err != nil {
245+ return nil, fmt.Errorf("failed to get agent status: %w", err)
246+ }
247+ defer rsp.Body.Close()
248+ if rsp.StatusCode != http.StatusOK {
249+ return nil, fmt.Errorf("failed to get agent status: status code %d", rsp.StatusCode)
250+ }
251+ var info proto.AgentInfo
252+ if err := json.NewDecoder(rsp.Body).Decode(&info); err != nil {
253+ return nil, fmt.Errorf("failed to decode agent status: %w", err)
254+ }
255+ return &info, nil
256+}
257+
258+// UpdateAgent triggers an agent model update on the server.
259+func (c *Client) UpdateAgent(ctx context.Context, id string) error {
260+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/update", id), nil, nil, nil)
261+ if err != nil {
262+ return fmt.Errorf("failed to update agent: %w", err)
263+ }
264+ defer rsp.Body.Close()
265+ if rsp.StatusCode != http.StatusOK {
266+ return fmt.Errorf("failed to update agent: status code %d", rsp.StatusCode)
267+ }
268+ return nil
269+}
270+
271+// SendMessage sends a message to the agent for a workspace.
272+func (c *Client) SendMessage(ctx context.Context, id string, sessionID, prompt string, attachments ...message.Attachment) error {
273+ protoAttachments := make([]proto.Attachment, len(attachments))
274+ for i, a := range attachments {
275+ protoAttachments[i] = proto.Attachment{
276+ FilePath: a.FilePath,
277+ FileName: a.FileName,
278+ MimeType: a.MimeType,
279+ Content: a.Content,
280+ }
281+ }
282+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent", id), nil, jsonBody(proto.AgentMessage{
283+ SessionID: sessionID,
284+ Prompt: prompt,
285+ Attachments: protoAttachments,
286+ }), http.Header{"Content-Type": []string{"application/json"}})
287+ if err != nil {
288+ return fmt.Errorf("failed to send message to agent: %w", err)
289+ }
290+ defer rsp.Body.Close()
291+ if rsp.StatusCode != http.StatusOK {
292+ return fmt.Errorf("failed to send message to agent: status code %d", rsp.StatusCode)
293+ }
294+ return nil
295+}
296+
297+// GetAgentSessionInfo retrieves the agent session info for a workspace.
298+func (c *Client) GetAgentSessionInfo(ctx context.Context, id string, sessionID string) (*proto.AgentSession, error) {
299+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s", id, sessionID), nil, nil)
300+ if err != nil {
301+ return nil, fmt.Errorf("failed to get session agent info: %w", err)
302+ }
303+ defer rsp.Body.Close()
304+ if rsp.StatusCode != http.StatusOK {
305+ return nil, fmt.Errorf("failed to get session agent info: status code %d", rsp.StatusCode)
306+ }
307+ var info proto.AgentSession
308+ if err := json.NewDecoder(rsp.Body).Decode(&info); err != nil {
309+ return nil, fmt.Errorf("failed to decode session agent info: %w", err)
310+ }
311+ return &info, nil
312+}
313+
314+// AgentSummarizeSession requests a session summarization.
315+func (c *Client) AgentSummarizeSession(ctx context.Context, id string, sessionID string) error {
316+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/summarize", id, sessionID), nil, nil, nil)
317+ if err != nil {
318+ return fmt.Errorf("failed to summarize session: %w", err)
319+ }
320+ defer rsp.Body.Close()
321+ if rsp.StatusCode != http.StatusOK {
322+ return fmt.Errorf("failed to summarize session: status code %d", rsp.StatusCode)
323+ }
324+ return nil
325+}
326+
327+// InitiateAgentProcessing triggers agent initialization on the server.
328+func (c *Client) InitiateAgentProcessing(ctx context.Context, id string) error {
329+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/init", id), nil, nil, nil)
330+ if err != nil {
331+ return fmt.Errorf("failed to initiate session agent processing: %w", err)
332+ }
333+ defer rsp.Body.Close()
334+ if rsp.StatusCode != http.StatusOK {
335+ return fmt.Errorf("failed to initiate session agent processing: status code %d", rsp.StatusCode)
336+ }
337+ return nil
338+}
339+
340+// ListMessages retrieves all messages for a session.
341+func (c *Client) ListMessages(ctx context.Context, id string, sessionID string) ([]message.Message, error) {
342+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/messages", id, sessionID), nil, nil)
343+ if err != nil {
344+ return nil, fmt.Errorf("failed to get messages: %w", err)
345+ }
346+ defer rsp.Body.Close()
347+ if rsp.StatusCode != http.StatusOK {
348+ return nil, fmt.Errorf("failed to get messages: status code %d", rsp.StatusCode)
349+ }
350+ var messages []message.Message
351+ if err := json.NewDecoder(rsp.Body).Decode(&messages); err != nil {
352+ return nil, fmt.Errorf("failed to decode messages: %w", err)
353+ }
354+ return messages, nil
355+}
356+
357+// GetSession retrieves a specific session.
358+func (c *Client) GetSession(ctx context.Context, id string, sessionID string) (*session.Session, error) {
359+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sessionID), nil, nil)
360+ if err != nil {
361+ return nil, fmt.Errorf("failed to get session: %w", err)
362+ }
363+ defer rsp.Body.Close()
364+ if rsp.StatusCode != http.StatusOK {
365+ return nil, fmt.Errorf("failed to get session: status code %d", rsp.StatusCode)
366+ }
367+ var sess session.Session
368+ if err := json.NewDecoder(rsp.Body).Decode(&sess); err != nil {
369+ return nil, fmt.Errorf("failed to decode session: %w", err)
370+ }
371+ return &sess, nil
372+}
373+
374+// ListSessionHistoryFiles retrieves history files for a session.
375+func (c *Client) ListSessionHistoryFiles(ctx context.Context, id string, sessionID string) ([]history.File, error) {
376+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/history", id, sessionID), nil, nil)
377+ if err != nil {
378+ return nil, fmt.Errorf("failed to get session history files: %w", err)
379+ }
380+ defer rsp.Body.Close()
381+ if rsp.StatusCode != http.StatusOK {
382+ return nil, fmt.Errorf("failed to get session history files: status code %d", rsp.StatusCode)
383+ }
384+ var files []history.File
385+ if err := json.NewDecoder(rsp.Body).Decode(&files); err != nil {
386+ return nil, fmt.Errorf("failed to decode session history files: %w", err)
387+ }
388+ return files, nil
389+}
390+
391+// CreateSession creates a new session in a workspace.
392+func (c *Client) CreateSession(ctx context.Context, id string, title string) (*session.Session, error) {
393+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/sessions", id), nil, jsonBody(session.Session{Title: title}), http.Header{"Content-Type": []string{"application/json"}})
394+ if err != nil {
395+ return nil, fmt.Errorf("failed to create session: %w", err)
396+ }
397+ defer rsp.Body.Close()
398+ if rsp.StatusCode != http.StatusOK {
399+ return nil, fmt.Errorf("failed to create session: status code %d", rsp.StatusCode)
400+ }
401+ var sess session.Session
402+ if err := json.NewDecoder(rsp.Body).Decode(&sess); err != nil {
403+ return nil, fmt.Errorf("failed to decode session: %w", err)
404+ }
405+ return &sess, nil
406+}
407+
408+// ListSessions lists all sessions in a workspace.
409+func (c *Client) ListSessions(ctx context.Context, id string) ([]session.Session, error) {
410+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions", id), nil, nil)
411+ if err != nil {
412+ return nil, fmt.Errorf("failed to get sessions: %w", err)
413+ }
414+ defer rsp.Body.Close()
415+ if rsp.StatusCode != http.StatusOK {
416+ return nil, fmt.Errorf("failed to get sessions: status code %d", rsp.StatusCode)
417+ }
418+ var sessions []session.Session
419+ if err := json.NewDecoder(rsp.Body).Decode(&sessions); err != nil {
420+ return nil, fmt.Errorf("failed to decode sessions: %w", err)
421+ }
422+ return sessions, nil
423+}
424+
425+// GrantPermission grants a permission on a workspace.
426+func (c *Client) GrantPermission(ctx context.Context, id string, req proto.PermissionGrant) error {
427+ rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/permissions/grant", id), nil, jsonBody(req), http.Header{"Content-Type": []string{"application/json"}})
428+ if err != nil {
429+ return fmt.Errorf("failed to grant permission: %w", err)
430+ }
431+ defer rsp.Body.Close()
432+ if rsp.StatusCode != http.StatusOK {
433+ return fmt.Errorf("failed to grant permission: status code %d", rsp.StatusCode)
434+ }
435+ return nil
436+}
437+
438+// SetPermissionsSkipRequests sets the skip-requests flag for a workspace.
439+func (c *Client) SetPermissionsSkipRequests(ctx context.Context, id string, skip bool) error {
440+ 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"}})
441+ if err != nil {
442+ return fmt.Errorf("failed to set permissions skip requests: %w", err)
443+ }
444+ defer rsp.Body.Close()
445+ if rsp.StatusCode != http.StatusOK {
446+ return fmt.Errorf("failed to set permissions skip requests: status code %d", rsp.StatusCode)
447+ }
448+ return nil
449+}
450+
451+// GetPermissionsSkipRequests retrieves the skip-requests flag for a workspace.
452+func (c *Client) GetPermissionsSkipRequests(ctx context.Context, id string) (bool, error) {
453+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/permissions/skip", id), nil, nil)
454+ if err != nil {
455+ return false, fmt.Errorf("failed to get permissions skip requests: %w", err)
456+ }
457+ defer rsp.Body.Close()
458+ if rsp.StatusCode != http.StatusOK {
459+ return false, fmt.Errorf("failed to get permissions skip requests: status code %d", rsp.StatusCode)
460+ }
461+ var skip proto.PermissionSkipRequest
462+ if err := json.NewDecoder(rsp.Body).Decode(&skip); err != nil {
463+ return false, fmt.Errorf("failed to decode permissions skip requests: %w", err)
464+ }
465+ return skip.Skip, nil
466+}
467+
468+// GetConfig retrieves the workspace-specific configuration.
469+func (c *Client) GetConfig(ctx context.Context, id string) (*config.Config, error) {
470+ rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/config", id), nil, nil)
471+ if err != nil {
472+ return nil, fmt.Errorf("failed to get config: %w", err)
473+ }
474+ defer rsp.Body.Close()
475+ if rsp.StatusCode != http.StatusOK {
476+ return nil, fmt.Errorf("failed to get config: status code %d", rsp.StatusCode)
477+ }
478+ var cfg config.Config
479+ if err := json.NewDecoder(rsp.Body).Decode(&cfg); err != nil {
480+ return nil, fmt.Errorf("failed to decode config: %w", err)
481+ }
482+ return &cfg, nil
483+}
484+
485+func jsonBody(v any) *bytes.Buffer {
486+ b := new(bytes.Buffer)
487+ m, _ := json.Marshal(v)
488+ b.Write(m)
489+ return b
490+}
internal/cmd/root.go+174
@@ -6,20 +6,29 @@ import (
66 "errors"
77 "fmt"
88 "io"
9+ "io/fs"
910 "log/slog"
11+ "net/url"
1012 "os"
13+ "os/exec"
1114 "path/filepath"
15+ "regexp"
1216 "strconv"
1317 "strings"
18+ "time"
1419
1520 tea "charm.land/bubbletea/v2"
1621 "charm.land/lipgloss/v2"
1722 "github.com/charmbracelet/colorprofile"
1823 "github.com/charmbracelet/crush/internal/app"
24+ "github.com/charmbracelet/crush/internal/client"
1925 "github.com/charmbracelet/crush/internal/config"
2026 "github.com/charmbracelet/crush/internal/db"
2127 "github.com/charmbracelet/crush/internal/event"
28+ "github.com/charmbracelet/crush/internal/log"
2229 "github.com/charmbracelet/crush/internal/projects"
30+ "github.com/charmbracelet/crush/internal/proto"
31+ "github.com/charmbracelet/crush/internal/server"
2332 "github.com/charmbracelet/crush/internal/ui/common"
2433 ui "github.com/charmbracelet/crush/internal/ui/model"
2534 "github.com/charmbracelet/crush/internal/version"
@@ -31,6 +40,8 @@ import (
3140 "github.com/spf13/cobra"
3241)
3342
43+var clientHost string
44+
3445func init() {
3546 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
3647 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
@@ -38,6 +49,8 @@ func init() {
3849 rootCmd.Flags().BoolP("help", "h", false, "Help")
3950 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
4051
52+ rootCmd.Flags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
53+
4154 rootCmd.AddCommand(
4255 runCmd,
4356 dirsCmd,
@@ -228,6 +241,167 @@ func setupApp(cmd *cobra.Command) (*app.App, error) {
228241 return appInstance, nil
229242}
230243
244+// setupClientApp sets up a client-based workspace via the server. It
245+// auto-starts a detached server process if the socket does not exist.
246+func setupClientApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *proto.Workspace, error) {
247+ debug, _ := cmd.Flags().GetBool("debug")
248+ yolo, _ := cmd.Flags().GetBool("yolo")
249+ dataDir, _ := cmd.Flags().GetString("data-dir")
250+ ctx := cmd.Context()
251+
252+ cwd, err := ResolveCwd(cmd)
253+ if err != nil {
254+ return nil, nil, err
255+ }
256+
257+ c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
258+ if err != nil {
259+ return nil, nil, err
260+ }
261+
262+ ws, err := c.CreateWorkspace(ctx, proto.Workspace{
263+ Path: cwd,
264+ DataDir: dataDir,
265+ Debug: debug,
266+ YOLO: yolo,
267+ Env: os.Environ(),
268+ })
269+ if err != nil {
270+ return nil, nil, fmt.Errorf("failed to create workspace: %v", err)
271+ }
272+
273+ cfg, err := c.GetGlobalConfig(cmd.Context())
274+ if err != nil {
275+ return nil, nil, fmt.Errorf("failed to get global config: %v", err)
276+ }
277+
278+ if shouldEnableMetrics(cfg) {
279+ event.Init()
280+ }
281+
282+ return c, ws, nil
283+}
284+
285+// ensureServer auto-starts a detached server if the socket file does not
286+// exist.
287+func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
288+ switch hostURL.Scheme {
289+ case "unix", "npipe":
290+ _, err := os.Stat(hostURL.Host)
291+ if err != nil && errors.Is(err, fs.ErrNotExist) {
292+ if err := startDetachedServer(cmd); err != nil {
293+ return err
294+ }
295+ }
296+
297+ for range 10 {
298+ _, err = os.Stat(hostURL.Host)
299+ if err == nil {
300+ break
301+ }
302+ select {
303+ case <-cmd.Context().Done():
304+ return cmd.Context().Err()
305+ case <-time.After(100 * time.Millisecond):
306+ }
307+ }
308+ if err != nil {
309+ return fmt.Errorf("failed to initialize crush server: %v", err)
310+ }
311+ default:
312+ // TCP: assume server is already running.
313+ }
314+ return nil
315+}
316+
317+// waitForHealth polls the server's health endpoint until it responds.
318+func waitForHealth(ctx context.Context, c *client.Client) error {
319+ var err error
320+ for range 10 {
321+ err = c.Health(ctx)
322+ if err == nil {
323+ return nil
324+ }
325+ select {
326+ case <-ctx.Done():
327+ return ctx.Err()
328+ case <-time.After(100 * time.Millisecond):
329+ }
330+ }
331+ return fmt.Errorf("failed to connect to crush server: %v", err)
332+}
333+
334+// streamEvents forwards SSE events from the client to the TUI program.
335+func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
336+ defer log.RecoverPanic("app.Subscribe", func() {
337+ slog.Info("TUI subscription panic: attempting graceful shutdown")
338+ p.Quit()
339+ })
340+
341+ for {
342+ select {
343+ case <-ctx.Done():
344+ slog.Debug("TUI message handler shutting down")
345+ return
346+ case ev, ok := <-evc:
347+ if !ok {
348+ slog.Debug("TUI message channel closed")
349+ return
350+ }
351+ p.Send(ev)
352+ }
353+ }
354+}
355+
356+var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
357+
358+func startDetachedServer(cmd *cobra.Command) error {
359+ exe, err := os.Executable()
360+ if err != nil {
361+ return fmt.Errorf("failed to get executable path: %v", err)
362+ }
363+
364+ safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
365+ chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
366+ if err := os.MkdirAll(chDir, 0o700); err != nil {
367+ return fmt.Errorf("failed to create server working directory: %v", err)
368+ }
369+
370+ cmdArgs := []string{"server"}
371+ if clientHost != server.DefaultHost() {
372+ cmdArgs = append(cmdArgs, "--host", clientHost)
373+ }
374+
375+ c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
376+ stdoutPath := filepath.Join(chDir, "stdout.log")
377+ stderrPath := filepath.Join(chDir, "stderr.log")
378+ detachProcess(c)
379+
380+ stdout, err := os.Create(stdoutPath)
381+ if err != nil {
382+ return fmt.Errorf("failed to create stdout log file: %v", err)
383+ }
384+ defer stdout.Close()
385+ c.Stdout = stdout
386+
387+ stderr, err := os.Create(stderrPath)
388+ if err != nil {
389+ return fmt.Errorf("failed to create stderr log file: %v", err)
390+ }
391+ defer stderr.Close()
392+ c.Stderr = stderr
393+
394+ if err := c.Start(); err != nil {
395+ return fmt.Errorf("failed to start crush server: %v", err)
396+ }
397+
398+ if err := c.Process.Release(); err != nil {
399+ return fmt.Errorf("failed to detach crush server process: %v", err)
400+ }
401+
402+ return nil
403+}
404+
231405func shouldEnableMetrics(cfg *config.Config) bool {
232406 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
233407 return false
internal/cmd/root_other.gonew file+16
@@ -0,0 +1,16 @@
1+//go:build !windows
2+// +build !windows
3+
4+package cmd
5+
6+import (
7+ "os/exec"
8+ "syscall"
9+)
10+
11+func detachProcess(c *exec.Cmd) {
12+ if c.SysProcAttr == nil {
13+ c.SysProcAttr = &syscall.SysProcAttr{}
14+ }
15+ c.SysProcAttr.Setsid = true
16+}
internal/cmd/root_windows.gonew file+18
@@ -0,0 +1,18 @@
1+//go:build windows
2+// +build windows
3+
4+package cmd
5+
6+import (
7+ "os/exec"
8+ "syscall"
9+
10+ "golang.org/x/sys/windows"
11+)
12+
13+func detachProcess(c *exec.Cmd) {
14+ if c.SysProcAttr == nil {
15+ c.SysProcAttr = &syscall.SysProcAttr{}
16+ }
17+ c.SysProcAttr.CreationFlags = syscall.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS
18+}
internal/cmd/server.gonew file+98
@@ -0,0 +1,98 @@
1+package cmd
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "log/slog"
8+ "os"
9+ "os/signal"
10+ "time"
11+
12+ "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/server"
14+ "github.com/spf13/cobra"
15+)
16+
17+var serverHost string
18+
19+func init() {
20+ serverCmd.Flags().StringVarP(&serverHost, "host", "H", server.DefaultHost(), "Server host (TCP or Unix socket)")
21+ rootCmd.AddCommand(serverCmd)
22+}
23+
24+var serverCmd = &cobra.Command{
25+ Use: "server",
26+ Short: "Start the Crush server",
27+ RunE: func(cmd *cobra.Command, _ []string) error {
28+ dataDir, err := cmd.Flags().GetString("data-dir")
29+ if err != nil {
30+ return fmt.Errorf("failed to get data directory: %v", err)
31+ }
32+ debug, err := cmd.Flags().GetBool("debug")
33+ if err != nil {
34+ return fmt.Errorf("failed to get debug flag: %v", err)
35+ }
36+
37+ cfg, err := config.Load("", dataDir, debug)
38+ if err != nil {
39+ return fmt.Errorf("failed to load configuration: %v", err)
40+ }
41+
42+ handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
43+ Level: slog.LevelInfo,
44+ })
45+ if debug {
46+ handler = slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
47+ Level: slog.LevelDebug,
48+ })
49+ }
50+ slog.SetDefault(slog.New(handler))
51+
52+ hostURL, err := server.ParseHostURL(serverHost)
53+ if err != nil {
54+ return fmt.Errorf("invalid server host: %v", err)
55+ }
56+
57+ srv := server.NewServer(cfg, hostURL.Scheme, hostURL.Host)
58+ srv.SetLogger(slog.Default())
59+ slog.Info("Starting Crush server...", "addr", serverHost)
60+
61+ errch := make(chan error, 1)
62+ sigch := make(chan os.Signal, 1)
63+ sigs := []os.Signal{os.Interrupt}
64+ sigs = append(sigs, addSignals(sigs)...)
65+ signal.Notify(sigch, sigs...)
66+
67+ go func() {
68+ errch <- srv.ListenAndServe()
69+ }()
70+
71+ select {
72+ case <-sigch:
73+ slog.Info("Received interrupt signal...")
74+ case err = <-errch:
75+ if err != nil && !errors.Is(err, server.ErrServerClosed) {
76+ _ = srv.Close()
77+ slog.Error("Server error", "error", err)
78+ return fmt.Errorf("server error: %v", err)
79+ }
80+ }
81+
82+ if errors.Is(err, server.ErrServerClosed) {
83+ return nil
84+ }
85+
86+ ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
87+ defer cancel()
88+
89+ slog.Info("Shutting down...")
90+
91+ if err := srv.Shutdown(ctx); err != nil {
92+ slog.Error("Failed to shutdown server", "error", err)
93+ return fmt.Errorf("failed to shutdown server: %v", err)
94+ }
95+
96+ return nil
97+ },
98+}
internal/cmd/server_other.gonew file+13
@@ -0,0 +1,13 @@
1+//go:build !windows
2+// +build !windows
3+
4+package cmd
5+
6+import (
7+ "os"
8+ "syscall"
9+)
10+
11+func addSignals(sigs []os.Signal) []os.Signal {
12+ return append(sigs, syscall.SIGTERM)
13+}
internal/cmd/server_windows.gonew file+10
@@ -0,0 +1,10 @@
1+//go:build windows
2+// +build windows
3+
4+package cmd
5+
6+import "os"
7+
8+func addSignals(sigs []os.Signal) []os.Signal {
9+ return sigs
10+}
internal/config/load.go+19
@@ -721,6 +721,25 @@ func GlobalConfig() string {
721721 return filepath.Join(home.Dir(), ".config", appName, fmt.Sprintf("%s.json", appName))
722722}
723723
724+// GlobalCacheDir returns the path to the global cache directory for the
725+// application.
726+func GlobalCacheDir() string {
727+ if crushCache := os.Getenv("CRUSH_CACHE_DIR"); crushCache != "" {
728+ return crushCache
729+ }
730+ if xdgCacheHome := os.Getenv("XDG_CACHE_HOME"); xdgCacheHome != "" {
731+ return filepath.Join(xdgCacheHome, appName)
732+ }
733+ if runtime.GOOS == "windows" {
734+ localAppData := cmp.Or(
735+ os.Getenv("LOCALAPPDATA"),
736+ filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local"),
737+ )
738+ return filepath.Join(localAppData, appName, "cache")
739+ }
740+ return filepath.Join(home.Dir(), ".cache", appName)
741+}
742+
724743// GlobalConfigData returns the path to the main data directory for the application.
725744// this config is used when the app overrides configurations instead of updating the global config.
726745func GlobalConfigData() string {
internal/proto/agent.gonew file+74
@@ -0,0 +1,74 @@
1+package proto
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+)
7+
8+// AgentEventType represents the type of agent event.
9+type AgentEventType string
10+
11+const (
12+ AgentEventTypeError AgentEventType = "error"
13+ AgentEventTypeResponse AgentEventType = "response"
14+ AgentEventTypeSummarize AgentEventType = "summarize"
15+)
16+
17+// MarshalText implements the [encoding.TextMarshaler] interface.
18+func (t AgentEventType) MarshalText() ([]byte, error) {
19+ return []byte(t), nil
20+}
21+
22+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
23+func (t *AgentEventType) UnmarshalText(text []byte) error {
24+ *t = AgentEventType(text)
25+ return nil
26+}
27+
28+// AgentEvent represents an event emitted by the agent.
29+type AgentEvent struct {
30+ Type AgentEventType `json:"type"`
31+ Message Message `json:"message"`
32+ Error error `json:"error,omitempty"`
33+
34+ // When summarizing.
35+ SessionID string `json:"session_id,omitempty"`
36+ Progress string `json:"progress,omitempty"`
37+ Done bool `json:"done,omitempty"`
38+}
39+
40+// MarshalJSON implements the [json.Marshaler] interface.
41+func (e AgentEvent) MarshalJSON() ([]byte, error) {
42+ type Alias AgentEvent
43+ return json.Marshal(&struct {
44+ Error string `json:"error,omitempty"`
45+ Alias
46+ }{
47+ Error: func() string {
48+ if e.Error != nil {
49+ return e.Error.Error()
50+ }
51+ return ""
52+ }(),
53+ Alias: (Alias)(e),
54+ })
55+}
56+
57+// UnmarshalJSON implements the [json.Unmarshaler] interface.
58+func (e *AgentEvent) UnmarshalJSON(data []byte) error {
59+ type Alias AgentEvent
60+ aux := &struct {
61+ Error string `json:"error,omitempty"`
62+ Alias
63+ }{
64+ Alias: (Alias)(*e),
65+ }
66+ if err := json.Unmarshal(data, &aux); err != nil {
67+ return err
68+ }
69+ *e = AgentEvent(aux.Alias)
70+ if aux.Error != "" {
71+ e.Error = errors.New(aux.Error)
72+ }
73+ return nil
74+}
internal/proto/history.gonew file+12
@@ -0,0 +1,12 @@
1+package proto
2+
3+// File represents a file tracked in session history.
4+type File struct {
5+ ID string `json:"id"`
6+ SessionID string `json:"session_id"`
7+ Path string `json:"path"`
8+ Content string `json:"content"`
9+ Version int64 `json:"version"`
10+ CreatedAt int64 `json:"created_at"`
11+ UpdatedAt int64 `json:"updated_at"`
12+}
internal/proto/mcp.gonew file+78
@@ -0,0 +1,78 @@
1+package proto
2+
3+import "fmt"
4+
5+// MCPState represents the current state of an MCP client.
6+type MCPState int
7+
8+const (
9+ MCPStateDisabled MCPState = iota
10+ MCPStateStarting
11+ MCPStateConnected
12+ MCPStateError
13+)
14+
15+// MarshalText implements the [encoding.TextMarshaler] interface.
16+func (s MCPState) MarshalText() ([]byte, error) {
17+ return []byte(s.String()), nil
18+}
19+
20+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
21+func (s *MCPState) UnmarshalText(data []byte) error {
22+ switch string(data) {
23+ case "disabled":
24+ *s = MCPStateDisabled
25+ case "starting":
26+ *s = MCPStateStarting
27+ case "connected":
28+ *s = MCPStateConnected
29+ case "error":
30+ *s = MCPStateError
31+ default:
32+ return fmt.Errorf("unknown mcp state: %s", data)
33+ }
34+ return nil
35+}
36+
37+// String returns the string representation of the MCPState.
38+func (s MCPState) String() string {
39+ switch s {
40+ case MCPStateDisabled:
41+ return "disabled"
42+ case MCPStateStarting:
43+ return "starting"
44+ case MCPStateConnected:
45+ return "connected"
46+ case MCPStateError:
47+ return "error"
48+ default:
49+ return "unknown"
50+ }
51+}
52+
53+// MCPEventType represents the type of MCP event.
54+type MCPEventType string
55+
56+const (
57+ MCPEventStateChanged MCPEventType = "state_changed"
58+)
59+
60+// MarshalText implements the [encoding.TextMarshaler] interface.
61+func (t MCPEventType) MarshalText() ([]byte, error) {
62+ return []byte(t), nil
63+}
64+
65+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
66+func (t *MCPEventType) UnmarshalText(data []byte) error {
67+ *t = MCPEventType(data)
68+ return nil
69+}
70+
71+// MCPEvent represents an event in the MCP system.
72+type MCPEvent struct {
73+ Type MCPEventType `json:"type"`
74+ Name string `json:"name"`
75+ State MCPState `json:"state"`
76+ Error error `json:"error,omitempty"`
77+ ToolCount int `json:"tool_count,omitempty"`
78+}
internal/proto/message.gonew file+653
@@ -0,0 +1,653 @@
1+package proto
2+
3+import (
4+ "encoding/base64"
5+ "encoding/json"
6+ "fmt"
7+ "slices"
8+ "time"
9+
10+ "charm.land/catwalk/pkg/catwalk"
11+)
12+
13+// CreateMessageParams represents parameters for creating a message.
14+type CreateMessageParams struct {
15+ Role MessageRole `json:"role"`
16+ Parts []ContentPart `json:"parts"`
17+ Model string `json:"model"`
18+ Provider string `json:"provider,omitempty"`
19+}
20+
21+// Message represents a message in the proto layer.
22+type Message struct {
23+ ID string `json:"id"`
24+ Role MessageRole `json:"role"`
25+ SessionID string `json:"session_id"`
26+ Parts []ContentPart `json:"parts"`
27+ Model string `json:"model"`
28+ Provider string `json:"provider"`
29+ CreatedAt int64 `json:"created_at"`
30+ UpdatedAt int64 `json:"updated_at"`
31+}
32+
33+// MessageRole represents the role of a message sender.
34+type MessageRole string
35+
36+const (
37+ Assistant MessageRole = "assistant"
38+ User MessageRole = "user"
39+ System MessageRole = "system"
40+ Tool MessageRole = "tool"
41+)
42+
43+// MarshalText implements the [encoding.TextMarshaler] interface.
44+func (r MessageRole) MarshalText() ([]byte, error) {
45+ return []byte(r), nil
46+}
47+
48+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
49+func (r *MessageRole) UnmarshalText(data []byte) error {
50+ *r = MessageRole(data)
51+ return nil
52+}
53+
54+// FinishReason represents why a message generation finished.
55+type FinishReason string
56+
57+const (
58+ FinishReasonEndTurn FinishReason = "end_turn"
59+ FinishReasonMaxTokens FinishReason = "max_tokens"
60+ FinishReasonToolUse FinishReason = "tool_use"
61+ FinishReasonCanceled FinishReason = "canceled"
62+ FinishReasonError FinishReason = "error"
63+ FinishReasonPermissionDenied FinishReason = "permission_denied"
64+ FinishReasonUnknown FinishReason = "unknown"
65+)
66+
67+// MarshalText implements the [encoding.TextMarshaler] interface.
68+func (fr FinishReason) MarshalText() ([]byte, error) {
69+ return []byte(fr), nil
70+}
71+
72+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
73+func (fr *FinishReason) UnmarshalText(data []byte) error {
74+ *fr = FinishReason(data)
75+ return nil
76+}
77+
78+// ContentPart is a part of a message's content.
79+type ContentPart interface {
80+ isPart()
81+}
82+
83+// ReasoningContent represents the reasoning/thinking part of a message.
84+type ReasoningContent struct {
85+ Thinking string `json:"thinking"`
86+ Signature string `json:"signature"`
87+ StartedAt int64 `json:"started_at,omitempty"`
88+ FinishedAt int64 `json:"finished_at,omitempty"`
89+}
90+
91+// String returns the thinking content as a string.
92+func (tc ReasoningContent) String() string {
93+ return tc.Thinking
94+}
95+
96+func (ReasoningContent) isPart() {}
97+
98+// TextContent represents a text part of a message.
99+type TextContent struct {
100+ Text string `json:"text"`
101+}
102+
103+// String returns the text content as a string.
104+func (tc TextContent) String() string {
105+ return tc.Text
106+}
107+
108+func (TextContent) isPart() {}
109+
110+// ImageURLContent represents an image URL part of a message.
111+type ImageURLContent struct {
112+ URL string `json:"url"`
113+ Detail string `json:"detail,omitempty"`
114+}
115+
116+// String returns the image URL as a string.
117+func (iuc ImageURLContent) String() string {
118+ return iuc.URL
119+}
120+
121+func (ImageURLContent) isPart() {}
122+
123+// BinaryContent represents binary data in a message.
124+type BinaryContent struct {
125+ Path string
126+ MIMEType string
127+ Data []byte
128+}
129+
130+// String returns a base64-encoded string of the binary data.
131+func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
132+ base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
133+ if p == catwalk.InferenceProviderOpenAI {
134+ return "data:" + bc.MIMEType + ";base64," + base64Encoded
135+ }
136+ return base64Encoded
137+}
138+
139+func (BinaryContent) isPart() {}
140+
141+// ToolCall represents a tool call in a message.
142+type ToolCall struct {
143+ ID string `json:"id"`
144+ Name string `json:"name"`
145+ Input string `json:"input"`
146+ Type string `json:"type,omitempty"`
147+ Finished bool `json:"finished,omitempty"`
148+}
149+
150+func (ToolCall) isPart() {}
151+
152+// ToolResult represents the result of a tool call.
153+type ToolResult struct {
154+ ToolCallID string `json:"tool_call_id"`
155+ Name string `json:"name"`
156+ Content string `json:"content"`
157+ Metadata string `json:"metadata"`
158+ IsError bool `json:"is_error"`
159+}
160+
161+func (ToolResult) isPart() {}
162+
163+// Finish represents the end of a message generation.
164+type Finish struct {
165+ Reason FinishReason `json:"reason"`
166+ Time int64 `json:"time"`
167+ Message string `json:"message,omitempty"`
168+ Details string `json:"details,omitempty"`
169+}
170+
171+func (Finish) isPart() {}
172+
173+// MarshalJSON implements the [json.Marshaler] interface.
174+func (m Message) MarshalJSON() ([]byte, error) {
175+ parts, err := MarshalParts(m.Parts)
176+ if err != nil {
177+ return nil, err
178+ }
179+
180+ type Alias Message
181+ return json.Marshal(&struct {
182+ Parts json.RawMessage `json:"parts"`
183+ *Alias
184+ }{
185+ Parts: json.RawMessage(parts),
186+ Alias: (*Alias)(&m),
187+ })
188+}
189+
190+// UnmarshalJSON implements the [json.Unmarshaler] interface.
191+func (m *Message) UnmarshalJSON(data []byte) error {
192+ type Alias Message
193+ aux := &struct {
194+ Parts json.RawMessage `json:"parts"`
195+ *Alias
196+ }{
197+ Alias: (*Alias)(m),
198+ }
199+
200+ if err := json.Unmarshal(data, &aux); err != nil {
201+ return err
202+ }
203+
204+ parts, err := UnmarshalParts([]byte(aux.Parts))
205+ if err != nil {
206+ return err
207+ }
208+
209+ m.Parts = parts
210+ return nil
211+}
212+
213+// Content returns the first text content part.
214+func (m *Message) Content() TextContent {
215+ for _, part := range m.Parts {
216+ if c, ok := part.(TextContent); ok {
217+ return c
218+ }
219+ }
220+ return TextContent{}
221+}
222+
223+// ReasoningContent returns the first reasoning content part.
224+func (m *Message) ReasoningContent() ReasoningContent {
225+ for _, part := range m.Parts {
226+ if c, ok := part.(ReasoningContent); ok {
227+ return c
228+ }
229+ }
230+ return ReasoningContent{}
231+}
232+
233+// ImageURLContent returns all image URL content parts.
234+func (m *Message) ImageURLContent() []ImageURLContent {
235+ imageURLContents := make([]ImageURLContent, 0)
236+ for _, part := range m.Parts {
237+ if c, ok := part.(ImageURLContent); ok {
238+ imageURLContents = append(imageURLContents, c)
239+ }
240+ }
241+ return imageURLContents
242+}
243+
244+// BinaryContent returns all binary content parts.
245+func (m *Message) BinaryContent() []BinaryContent {
246+ binaryContents := make([]BinaryContent, 0)
247+ for _, part := range m.Parts {
248+ if c, ok := part.(BinaryContent); ok {
249+ binaryContents = append(binaryContents, c)
250+ }
251+ }
252+ return binaryContents
253+}
254+
255+// ToolCalls returns all tool call parts.
256+func (m *Message) ToolCalls() []ToolCall {
257+ toolCalls := make([]ToolCall, 0)
258+ for _, part := range m.Parts {
259+ if c, ok := part.(ToolCall); ok {
260+ toolCalls = append(toolCalls, c)
261+ }
262+ }
263+ return toolCalls
264+}
265+
266+// ToolResults returns all tool result parts.
267+func (m *Message) ToolResults() []ToolResult {
268+ toolResults := make([]ToolResult, 0)
269+ for _, part := range m.Parts {
270+ if c, ok := part.(ToolResult); ok {
271+ toolResults = append(toolResults, c)
272+ }
273+ }
274+ return toolResults
275+}
276+
277+// IsFinished returns true if the message has a finish part.
278+func (m *Message) IsFinished() bool {
279+ for _, part := range m.Parts {
280+ if _, ok := part.(Finish); ok {
281+ return true
282+ }
283+ }
284+ return false
285+}
286+
287+// FinishPart returns the finish part if present.
288+func (m *Message) FinishPart() *Finish {
289+ for _, part := range m.Parts {
290+ if c, ok := part.(Finish); ok {
291+ return &c
292+ }
293+ }
294+ return nil
295+}
296+
297+// FinishReason returns the finish reason if present.
298+func (m *Message) FinishReason() FinishReason {
299+ for _, part := range m.Parts {
300+ if c, ok := part.(Finish); ok {
301+ return c.Reason
302+ }
303+ }
304+ return ""
305+}
306+
307+// IsThinking returns true if the message is currently in a thinking state.
308+func (m *Message) IsThinking() bool {
309+ return m.ReasoningContent().Thinking != "" && m.Content().Text == "" && !m.IsFinished()
310+}
311+
312+// AppendContent appends text to the text content part.
313+func (m *Message) AppendContent(delta string) {
314+ found := false
315+ for i, part := range m.Parts {
316+ if c, ok := part.(TextContent); ok {
317+ m.Parts[i] = TextContent{Text: c.Text + delta}
318+ found = true
319+ }
320+ }
321+ if !found {
322+ m.Parts = append(m.Parts, TextContent{Text: delta})
323+ }
324+}
325+
326+// AppendReasoningContent appends text to the reasoning content part.
327+func (m *Message) AppendReasoningContent(delta string) {
328+ found := false
329+ for i, part := range m.Parts {
330+ if c, ok := part.(ReasoningContent); ok {
331+ m.Parts[i] = ReasoningContent{
332+ Thinking: c.Thinking + delta,
333+ Signature: c.Signature,
334+ StartedAt: c.StartedAt,
335+ FinishedAt: c.FinishedAt,
336+ }
337+ found = true
338+ }
339+ }
340+ if !found {
341+ m.Parts = append(m.Parts, ReasoningContent{
342+ Thinking: delta,
343+ StartedAt: time.Now().Unix(),
344+ })
345+ }
346+}
347+
348+// AppendReasoningSignature appends a signature to the reasoning content part.
349+func (m *Message) AppendReasoningSignature(signature string) {
350+ for i, part := range m.Parts {
351+ if c, ok := part.(ReasoningContent); ok {
352+ m.Parts[i] = ReasoningContent{
353+ Thinking: c.Thinking,
354+ Signature: c.Signature + signature,
355+ StartedAt: c.StartedAt,
356+ FinishedAt: c.FinishedAt,
357+ }
358+ return
359+ }
360+ }
361+ m.Parts = append(m.Parts, ReasoningContent{Signature: signature})
362+}
363+
364+// FinishThinking marks the reasoning content as finished.
365+func (m *Message) FinishThinking() {
366+ for i, part := range m.Parts {
367+ if c, ok := part.(ReasoningContent); ok {
368+ if c.FinishedAt == 0 {
369+ m.Parts[i] = ReasoningContent{
370+ Thinking: c.Thinking,
371+ Signature: c.Signature,
372+ StartedAt: c.StartedAt,
373+ FinishedAt: time.Now().Unix(),
374+ }
375+ }
376+ return
377+ }
378+ }
379+}
380+
381+// ThinkingDuration returns the duration of the thinking phase.
382+func (m *Message) ThinkingDuration() time.Duration {
383+ reasoning := m.ReasoningContent()
384+ if reasoning.StartedAt == 0 {
385+ return 0
386+ }
387+
388+ endTime := reasoning.FinishedAt
389+ if endTime == 0 {
390+ endTime = time.Now().Unix()
391+ }
392+
393+ return time.Duration(endTime-reasoning.StartedAt) * time.Second
394+}
395+
396+// FinishToolCall marks a tool call as finished.
397+func (m *Message) FinishToolCall(toolCallID string) {
398+ for i, part := range m.Parts {
399+ if c, ok := part.(ToolCall); ok {
400+ if c.ID == toolCallID {
401+ m.Parts[i] = ToolCall{
402+ ID: c.ID,
403+ Name: c.Name,
404+ Input: c.Input,
405+ Type: c.Type,
406+ Finished: true,
407+ }
408+ return
409+ }
410+ }
411+ }
412+}
413+
414+// AppendToolCallInput appends input to a tool call.
415+func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) {
416+ for i, part := range m.Parts {
417+ if c, ok := part.(ToolCall); ok {
418+ if c.ID == toolCallID {
419+ m.Parts[i] = ToolCall{
420+ ID: c.ID,
421+ Name: c.Name,
422+ Input: c.Input + inputDelta,
423+ Type: c.Type,
424+ Finished: c.Finished,
425+ }
426+ return
427+ }
428+ }
429+ }
430+}
431+
432+// AddToolCall adds or updates a tool call.
433+func (m *Message) AddToolCall(tc ToolCall) {
434+ for i, part := range m.Parts {
435+ if c, ok := part.(ToolCall); ok {
436+ if c.ID == tc.ID {
437+ m.Parts[i] = tc
438+ return
439+ }
440+ }
441+ }
442+ m.Parts = append(m.Parts, tc)
443+}
444+
445+// SetToolCalls replaces all tool call parts.
446+func (m *Message) SetToolCalls(tc []ToolCall) {
447+ parts := make([]ContentPart, 0)
448+ for _, part := range m.Parts {
449+ if _, ok := part.(ToolCall); ok {
450+ continue
451+ }
452+ parts = append(parts, part)
453+ }
454+ m.Parts = parts
455+ for _, toolCall := range tc {
456+ m.Parts = append(m.Parts, toolCall)
457+ }
458+}
459+
460+// AddToolResult adds a tool result.
461+func (m *Message) AddToolResult(tr ToolResult) {
462+ m.Parts = append(m.Parts, tr)
463+}
464+
465+// SetToolResults adds multiple tool results.
466+func (m *Message) SetToolResults(tr []ToolResult) {
467+ for _, toolResult := range tr {
468+ m.Parts = append(m.Parts, toolResult)
469+ }
470+}
471+
472+// AddFinish adds a finish part to the message.
473+func (m *Message) AddFinish(reason FinishReason, message, details string) {
474+ for i, part := range m.Parts {
475+ if _, ok := part.(Finish); ok {
476+ m.Parts = slices.Delete(m.Parts, i, i+1)
477+ break
478+ }
479+ }
480+ m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix(), Message: message, Details: details})
481+}
482+
483+// AddImageURL adds an image URL part to the message.
484+func (m *Message) AddImageURL(url, detail string) {
485+ m.Parts = append(m.Parts, ImageURLContent{URL: url, Detail: detail})
486+}
487+
488+// AddBinary adds a binary content part to the message.
489+func (m *Message) AddBinary(mimeType string, data []byte) {
490+ m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
491+}
492+
493+type partType string
494+
495+const (
496+ reasoningType partType = "reasoning"
497+ textType partType = "text"
498+ imageURLType partType = "image_url"
499+ binaryType partType = "binary"
500+ toolCallType partType = "tool_call"
501+ toolResultType partType = "tool_result"
502+ finishType partType = "finish"
503+)
504+
505+type partWrapper struct {
506+ Type partType `json:"type"`
507+ Data ContentPart `json:"data"`
508+}
509+
510+// MarshalParts marshals content parts to JSON.
511+func MarshalParts(parts []ContentPart) ([]byte, error) {
512+ wrappedParts := make([]partWrapper, len(parts))
513+
514+ for i, part := range parts {
515+ var typ partType
516+
517+ switch part.(type) {
518+ case ReasoningContent:
519+ typ = reasoningType
520+ case TextContent:
521+ typ = textType
522+ case ImageURLContent:
523+ typ = imageURLType
524+ case BinaryContent:
525+ typ = binaryType
526+ case ToolCall:
527+ typ = toolCallType
528+ case ToolResult:
529+ typ = toolResultType
530+ case Finish:
531+ typ = finishType
532+ default:
533+ return nil, fmt.Errorf("unknown part type: %T", part)
534+ }
535+
536+ wrappedParts[i] = partWrapper{
537+ Type: typ,
538+ Data: part,
539+ }
540+ }
541+ return json.Marshal(wrappedParts)
542+}
543+
544+// UnmarshalParts unmarshals content parts from JSON.
545+func UnmarshalParts(data []byte) ([]ContentPart, error) {
546+ temp := []json.RawMessage{}
547+
548+ if err := json.Unmarshal(data, &temp); err != nil {
549+ return nil, err
550+ }
551+
552+ parts := make([]ContentPart, 0)
553+
554+ for _, rawPart := range temp {
555+ var wrapper struct {
556+ Type partType `json:"type"`
557+ Data json.RawMessage `json:"data"`
558+ }
559+
560+ if err := json.Unmarshal(rawPart, &wrapper); err != nil {
561+ return nil, err
562+ }
563+
564+ switch wrapper.Type {
565+ case reasoningType:
566+ part := ReasoningContent{}
567+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
568+ return nil, err
569+ }
570+ parts = append(parts, part)
571+ case textType:
572+ part := TextContent{}
573+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
574+ return nil, err
575+ }
576+ parts = append(parts, part)
577+ case imageURLType:
578+ part := ImageURLContent{}
579+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
580+ return nil, err
581+ }
582+ parts = append(parts, part)
583+ case binaryType:
584+ part := BinaryContent{}
585+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
586+ return nil, err
587+ }
588+ parts = append(parts, part)
589+ case toolCallType:
590+ part := ToolCall{}
591+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
592+ return nil, err
593+ }
594+ parts = append(parts, part)
595+ case toolResultType:
596+ part := ToolResult{}
597+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
598+ return nil, err
599+ }
600+ parts = append(parts, part)
601+ case finishType:
602+ part := Finish{}
603+ if err := json.Unmarshal(wrapper.Data, &part); err != nil {
604+ return nil, err
605+ }
606+ parts = append(parts, part)
607+ default:
608+ return nil, fmt.Errorf("unknown part type: %s", wrapper.Type)
609+ }
610+ }
611+
612+ return parts, nil
613+}
614+
615+// Attachment represents a file attachment.
616+type Attachment struct {
617+ FilePath string `json:"file_path"`
618+ FileName string `json:"file_name"`
619+ MimeType string `json:"mime_type"`
620+ Content []byte `json:"content"`
621+}
622+
623+// MarshalJSON implements the [json.Marshaler] interface.
624+func (a Attachment) MarshalJSON() ([]byte, error) {
625+ type Alias Attachment
626+ return json.Marshal(&struct {
627+ Content string `json:"content"`
628+ *Alias
629+ }{
630+ Content: base64.StdEncoding.EncodeToString(a.Content),
631+ Alias: (*Alias)(&a),
632+ })
633+}
634+
635+// UnmarshalJSON implements the [json.Unmarshaler] interface.
636+func (a *Attachment) UnmarshalJSON(data []byte) error {
637+ type Alias Attachment
638+ aux := &struct {
639+ Content string `json:"content"`
640+ *Alias
641+ }{
642+ Alias: (*Alias)(a),
643+ }
644+ if err := json.Unmarshal(data, &aux); err != nil {
645+ return err
646+ }
647+ content, err := base64.StdEncoding.DecodeString(aux.Content)
648+ if err != nil {
649+ return err
650+ }
651+ a.Content = content
652+ return nil
653+}
internal/proto/permission.gonew file+141
@@ -0,0 +1,141 @@
1+package proto
2+
3+import (
4+ "encoding/json"
5+)
6+
7+// CreatePermissionRequest represents a request to create a permission.
8+type CreatePermissionRequest struct {
9+ SessionID string `json:"session_id"`
10+ ToolCallID string `json:"tool_call_id"`
11+ ToolName string `json:"tool_name"`
12+ Description string `json:"description"`
13+ Action string `json:"action"`
14+ Params any `json:"params"`
15+ Path string `json:"path"`
16+}
17+
18+// PermissionNotification represents a notification about a permission change.
19+type PermissionNotification struct {
20+ ToolCallID string `json:"tool_call_id"`
21+ Granted bool `json:"granted"`
22+ Denied bool `json:"denied"`
23+}
24+
25+// PermissionRequest represents a pending permission request.
26+type PermissionRequest struct {
27+ ID string `json:"id"`
28+ SessionID string `json:"session_id"`
29+ ToolCallID string `json:"tool_call_id"`
30+ ToolName string `json:"tool_name"`
31+ Description string `json:"description"`
32+ Action string `json:"action"`
33+ Params any `json:"params"`
34+ Path string `json:"path"`
35+}
36+
37+// UnmarshalJSON implements the json.Unmarshaler interface. This is needed
38+// because the Params field is of type any, so we need to unmarshal it into
39+// its appropriate type based on the [PermissionRequest.ToolName].
40+func (p *PermissionRequest) UnmarshalJSON(data []byte) error {
41+ type Alias PermissionRequest
42+ aux := &struct {
43+ Params json.RawMessage `json:"params"`
44+ *Alias
45+ }{
46+ Alias: (*Alias)(p),
47+ }
48+ if err := json.Unmarshal(data, &aux); err != nil {
49+ return err
50+ }
51+
52+ params, err := unmarshalToolParams(p.ToolName, aux.Params)
53+ if err != nil {
54+ return err
55+ }
56+ p.Params = params
57+ return nil
58+}
59+
60+// UnmarshalJSON implements the json.Unmarshaler interface. This is needed
61+// because the Params field is of type any, so we need to unmarshal it into
62+// its appropriate type based on the [CreatePermissionRequest.ToolName].
63+func (p *CreatePermissionRequest) UnmarshalJSON(data []byte) error {
64+ type Alias CreatePermissionRequest
65+ aux := &struct {
66+ Params json.RawMessage `json:"params"`
67+ *Alias
68+ }{
69+ Alias: (*Alias)(p),
70+ }
71+ if err := json.Unmarshal(data, &aux); err != nil {
72+ return err
73+ }
74+
75+ params, err := unmarshalToolParams(p.ToolName, aux.Params)
76+ if err != nil {
77+ return err
78+ }
79+ p.Params = params
80+ return nil
81+}
82+
83+func unmarshalToolParams(toolName string, raw json.RawMessage) (any, error) {
84+ switch toolName {
85+ case BashToolName:
86+ var params BashPermissionsParams
87+ if err := json.Unmarshal(raw, &params); err != nil {
88+ return nil, err
89+ }
90+ return params, nil
91+ case DownloadToolName:
92+ var params DownloadPermissionsParams
93+ if err := json.Unmarshal(raw, &params); err != nil {
94+ return nil, err
95+ }
96+ return params, nil
97+ case EditToolName:
98+ var params EditPermissionsParams
99+ if err := json.Unmarshal(raw, &params); err != nil {
100+ return nil, err
101+ }
102+ return params, nil
103+ case WriteToolName:
104+ var params WritePermissionsParams
105+ if err := json.Unmarshal(raw, &params); err != nil {
106+ return nil, err
107+ }
108+ return params, nil
109+ case MultiEditToolName:
110+ var params MultiEditPermissionsParams
111+ if err := json.Unmarshal(raw, &params); err != nil {
112+ return nil, err
113+ }
114+ return params, nil
115+ case FetchToolName:
116+ var params FetchPermissionsParams
117+ if err := json.Unmarshal(raw, &params); err != nil {
118+ return nil, err
119+ }
120+ return params, nil
121+ case ViewToolName:
122+ var params ViewPermissionsParams
123+ if err := json.Unmarshal(raw, &params); err != nil {
124+ return nil, err
125+ }
126+ return params, nil
127+ case LSToolName:
128+ var params LSPermissionsParams
129+ if err := json.Unmarshal(raw, &params); err != nil {
130+ return nil, err
131+ }
132+ return params, nil
133+ default:
134+ // For unknown tools, keep the raw JSON as-is.
135+ var generic map[string]any
136+ if err := json.Unmarshal(raw, &generic); err != nil {
137+ return nil, err
138+ }
139+ return generic, nil
140+ }
141+}
internal/proto/proto.gonew file+123
@@ -0,0 +1,123 @@
1+package proto
2+
3+import (
4+ "time"
5+
6+ "charm.land/catwalk/pkg/catwalk"
7+ "github.com/charmbracelet/crush/internal/config"
8+ "github.com/charmbracelet/crush/internal/lsp"
9+)
10+
11+// Workspace represents a running app.App workspace with its associated
12+// resources and state.
13+type Workspace struct {
14+ ID string `json:"id"`
15+ Path string `json:"path"`
16+ YOLO bool `json:"yolo,omitempty"`
17+ Debug bool `json:"debug,omitempty"`
18+ DataDir string `json:"data_dir,omitempty"`
19+ Config *config.Config `json:"config,omitempty"`
20+ Env []string `json:"env,omitempty"`
21+}
22+
23+// Error represents an error response.
24+type Error struct {
25+ Message string `json:"message"`
26+}
27+
28+// AgentInfo represents information about the agent.
29+type AgentInfo struct {
30+ IsBusy bool `json:"is_busy"`
31+ Model catwalk.Model `json:"model"`
32+}
33+
34+// IsZero checks if the AgentInfo is zero-valued.
35+func (a AgentInfo) IsZero() bool {
36+ return !a.IsBusy && a.Model.ID == ""
37+}
38+
39+// AgentMessage represents a message sent to the agent.
40+type AgentMessage struct {
41+ SessionID string `json:"session_id"`
42+ Prompt string `json:"prompt"`
43+ Attachments []Attachment `json:"attachments,omitempty"`
44+}
45+
46+// AgentSession represents a session with its busy status.
47+type AgentSession struct {
48+ Session
49+ IsBusy bool `json:"is_busy"`
50+}
51+
52+// IsZero checks if the AgentSession is zero-valued.
53+func (a AgentSession) IsZero() bool {
54+ return a == AgentSession{}
55+}
56+
57+// PermissionAction represents an action taken on a permission request.
58+type PermissionAction string
59+
60+const (
61+ PermissionAllow PermissionAction = "allow"
62+ PermissionAllowForSession PermissionAction = "allow_session"
63+ PermissionDeny PermissionAction = "deny"
64+)
65+
66+// MarshalText implements the [encoding.TextMarshaler] interface.
67+func (p PermissionAction) MarshalText() ([]byte, error) {
68+ return []byte(p), nil
69+}
70+
71+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
72+func (p *PermissionAction) UnmarshalText(text []byte) error {
73+ *p = PermissionAction(text)
74+ return nil
75+}
76+
77+// PermissionGrant represents a permission grant request.
78+type PermissionGrant struct {
79+ Permission PermissionRequest `json:"permission"`
80+ Action PermissionAction `json:"action"`
81+}
82+
83+// PermissionSkipRequest represents a request to skip permission prompts.
84+type PermissionSkipRequest struct {
85+ Skip bool `json:"skip"`
86+}
87+
88+// LSPEventType represents the type of LSP event.
89+type LSPEventType string
90+
91+const (
92+ LSPEventStateChanged LSPEventType = "state_changed"
93+ LSPEventDiagnosticsChanged LSPEventType = "diagnostics_changed"
94+)
95+
96+// MarshalText implements the [encoding.TextMarshaler] interface.
97+func (e LSPEventType) MarshalText() ([]byte, error) {
98+ return []byte(e), nil
99+}
100+
101+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
102+func (e *LSPEventType) UnmarshalText(data []byte) error {
103+ *e = LSPEventType(data)
104+ return nil
105+}
106+
107+// LSPEvent represents an event in the LSP system.
108+type LSPEvent struct {
109+ Type LSPEventType `json:"type"`
110+ Name string `json:"name"`
111+ State lsp.ServerState `json:"state"`
112+ Error error `json:"error,omitempty"`
113+ DiagnosticCount int `json:"diagnostic_count,omitempty"`
114+}
115+
116+// LSPClientInfo holds information about an LSP client's state.
117+type LSPClientInfo struct {
118+ Name string `json:"name"`
119+ State lsp.ServerState `json:"state"`
120+ Error error `json:"error,omitempty"`
121+ DiagnosticCount int `json:"diagnostic_count,omitempty"`
122+ ConnectedAt time.Time `json:"connected_at"`
123+}
internal/proto/server.gonew file+6
@@ -0,0 +1,6 @@
1+package proto
2+
3+// ServerControl represents a server control request.
4+type ServerControl struct {
5+ Command string `json:"command"`
6+}
internal/proto/session.gonew file+15
@@ -0,0 +1,15 @@
1+package proto
2+
3+// Session represents a session in the proto layer.
4+type Session struct {
5+ ID string `json:"id"`
6+ ParentSessionID string `json:"parent_session_id"`
7+ Title string `json:"title"`
8+ MessageCount int64 `json:"message_count"`
9+ PromptTokens int64 `json:"prompt_tokens"`
10+ CompletionTokens int64 `json:"completion_tokens"`
11+ SummaryMessageID string `json:"summary_message_id"`
12+ Cost float64 `json:"cost"`
13+ CreatedAt int64 `json:"created_at"`
14+ UpdatedAt int64 `json:"updated_at"`
15+}
internal/proto/tools.gonew file+250
@@ -0,0 +1,250 @@
1+package proto
2+
3+// ToolResponseType represents the type of tool response.
4+type ToolResponseType string
5+
6+const (
7+ ToolResponseTypeText ToolResponseType = "text"
8+ ToolResponseTypeImage ToolResponseType = "image"
9+)
10+
11+// ToolResponse represents a response from a tool.
12+type ToolResponse struct {
13+ Type ToolResponseType `json:"type"`
14+ Content string `json:"content"`
15+ Metadata string `json:"metadata,omitempty"`
16+ IsError bool `json:"is_error"`
17+}
18+
19+const BashToolName = "bash"
20+
21+// BashParams represents the parameters for the bash tool.
22+type BashParams struct {
23+ Command string `json:"command"`
24+ Timeout int `json:"timeout"`
25+}
26+
27+// BashPermissionsParams represents the permission parameters for the bash tool.
28+type BashPermissionsParams struct {
29+ Command string `json:"command"`
30+ Timeout int `json:"timeout"`
31+}
32+
33+// BashResponseMetadata represents the metadata for a bash tool response.
34+type BashResponseMetadata struct {
35+ StartTime int64 `json:"start_time"`
36+ EndTime int64 `json:"end_time"`
37+ Output string `json:"output"`
38+ WorkingDirectory string `json:"working_directory"`
39+}
40+
41+// DiagnosticsParams represents the parameters for the diagnostics tool.
42+type DiagnosticsParams struct {
43+ FilePath string `json:"file_path"`
44+}
45+
46+const DownloadToolName = "download"
47+
48+// DownloadParams represents the parameters for the download tool.
49+type DownloadParams struct {
50+ URL string `json:"url"`
51+ FilePath string `json:"file_path"`
52+ Timeout int `json:"timeout,omitempty"`
53+}
54+
55+// DownloadPermissionsParams represents the permission parameters for the download tool.
56+type DownloadPermissionsParams struct {
57+ URL string `json:"url"`
58+ FilePath string `json:"file_path"`
59+ Timeout int `json:"timeout,omitempty"`
60+}
61+
62+const EditToolName = "edit"
63+
64+// EditParams represents the parameters for the edit tool.
65+type EditParams struct {
66+ FilePath string `json:"file_path"`
67+ OldString string `json:"old_string"`
68+ NewString string `json:"new_string"`
69+ ReplaceAll bool `json:"replace_all,omitempty"`
70+}
71+
72+// EditPermissionsParams represents the permission parameters for the edit tool.
73+type EditPermissionsParams struct {
74+ FilePath string `json:"file_path"`
75+ OldContent string `json:"old_content,omitempty"`
76+ NewContent string `json:"new_content,omitempty"`
77+}
78+
79+// EditResponseMetadata represents the metadata for an edit tool response.
80+type EditResponseMetadata struct {
81+ Additions int `json:"additions"`
82+ Removals int `json:"removals"`
83+ OldContent string `json:"old_content,omitempty"`
84+ NewContent string `json:"new_content,omitempty"`
85+}
86+
87+const FetchToolName = "fetch"
88+
89+// FetchParams represents the parameters for the fetch tool.
90+type FetchParams struct {
91+ URL string `json:"url"`
92+ Format string `json:"format"`
93+ Timeout int `json:"timeout,omitempty"`
94+}
95+
96+// FetchPermissionsParams represents the permission parameters for the fetch tool.
97+type FetchPermissionsParams struct {
98+ URL string `json:"url"`
99+ Format string `json:"format"`
100+ Timeout int `json:"timeout,omitempty"`
101+}
102+
103+const GlobToolName = "glob"
104+
105+// GlobParams represents the parameters for the glob tool.
106+type GlobParams struct {
107+ Pattern string `json:"pattern"`
108+ Path string `json:"path"`
109+}
110+
111+// GlobResponseMetadata represents the metadata for a glob tool response.
112+type GlobResponseMetadata struct {
113+ NumberOfFiles int `json:"number_of_files"`
114+ Truncated bool `json:"truncated"`
115+}
116+
117+const GrepToolName = "grep"
118+
119+// GrepParams represents the parameters for the grep tool.
120+type GrepParams struct {
121+ Pattern string `json:"pattern"`
122+ Path string `json:"path"`
123+ Include string `json:"include"`
124+ LiteralText bool `json:"literal_text"`
125+}
126+
127+// GrepResponseMetadata represents the metadata for a grep tool response.
128+type GrepResponseMetadata struct {
129+ NumberOfMatches int `json:"number_of_matches"`
130+ Truncated bool `json:"truncated"`
131+}
132+
133+const LSToolName = "ls"
134+
135+// LSParams represents the parameters for the ls tool.
136+type LSParams struct {
137+ Path string `json:"path"`
138+ Ignore []string `json:"ignore"`
139+}
140+
141+// LSPermissionsParams represents the permission parameters for the ls tool.
142+type LSPermissionsParams struct {
143+ Path string `json:"path"`
144+ Ignore []string `json:"ignore"`
145+}
146+
147+// TreeNode represents a node in a directory tree.
148+type TreeNode struct {
149+ Name string `json:"name"`
150+ Path string `json:"path"`
151+ Type string `json:"type"`
152+ Children []*TreeNode `json:"children,omitempty"`
153+}
154+
155+// LSResponseMetadata represents the metadata for an ls tool response.
156+type LSResponseMetadata struct {
157+ NumberOfFiles int `json:"number_of_files"`
158+ Truncated bool `json:"truncated"`
159+}
160+
161+const MultiEditToolName = "multiedit"
162+
163+// MultiEditOperation represents a single edit operation in a multi-edit.
164+type MultiEditOperation struct {
165+ OldString string `json:"old_string"`
166+ NewString string `json:"new_string"`
167+ ReplaceAll bool `json:"replace_all,omitempty"`
168+}
169+
170+// MultiEditParams represents the parameters for the multi-edit tool.
171+type MultiEditParams struct {
172+ FilePath string `json:"file_path"`
173+ Edits []MultiEditOperation `json:"edits"`
174+}
175+
176+// MultiEditPermissionsParams represents the permission parameters for the multi-edit tool.
177+type MultiEditPermissionsParams struct {
178+ FilePath string `json:"file_path"`
179+ OldContent string `json:"old_content,omitempty"`
180+ NewContent string `json:"new_content,omitempty"`
181+}
182+
183+// MultiEditResponseMetadata represents the metadata for a multi-edit tool response.
184+type MultiEditResponseMetadata struct {
185+ Additions int `json:"additions"`
186+ Removals int `json:"removals"`
187+ OldContent string `json:"old_content,omitempty"`
188+ NewContent string `json:"new_content,omitempty"`
189+ EditsApplied int `json:"edits_applied"`
190+}
191+
192+const SourcegraphToolName = "sourcegraph"
193+
194+// SourcegraphParams represents the parameters for the sourcegraph tool.
195+type SourcegraphParams struct {
196+ Query string `json:"query"`
197+ Count int `json:"count,omitempty"`
198+ ContextWindow int `json:"context_window,omitempty"`
199+ Timeout int `json:"timeout,omitempty"`
200+}
201+
202+// SourcegraphResponseMetadata represents the metadata for a sourcegraph tool response.
203+type SourcegraphResponseMetadata struct {
204+ NumberOfMatches int `json:"number_of_matches"`
205+ Truncated bool `json:"truncated"`
206+}
207+
208+const ViewToolName = "view"
209+
210+// ViewParams represents the parameters for the view tool.
211+type ViewParams struct {
212+ FilePath string `json:"file_path"`
213+ Offset int `json:"offset"`
214+ Limit int `json:"limit"`
215+}
216+
217+// ViewPermissionsParams represents the permission parameters for the view tool.
218+type ViewPermissionsParams struct {
219+ FilePath string `json:"file_path"`
220+ Offset int `json:"offset"`
221+ Limit int `json:"limit"`
222+}
223+
224+// ViewResponseMetadata represents the metadata for a view tool response.
225+type ViewResponseMetadata struct {
226+ FilePath string `json:"file_path"`
227+ Content string `json:"content"`
228+}
229+
230+const WriteToolName = "write"
231+
232+// WriteParams represents the parameters for the write tool.
233+type WriteParams struct {
234+ FilePath string `json:"file_path"`
235+ Content string `json:"content"`
236+}
237+
238+// WritePermissionsParams represents the permission parameters for the write tool.
239+type WritePermissionsParams struct {
240+ FilePath string `json:"file_path"`
241+ OldContent string `json:"old_content,omitempty"`
242+ NewContent string `json:"new_content,omitempty"`
243+}
244+
245+// WriteResponseMetadata represents the metadata for a write tool response.
246+type WriteResponseMetadata struct {
247+ Diff string `json:"diff"`
248+ Additions int `json:"additions"`
249+ Removals int `json:"removals"`
250+}
internal/proto/version.gonew file+9
@@ -0,0 +1,9 @@
1+package proto
2+
3+// VersionInfo represents version information about the server.
4+type VersionInfo struct {
5+ Version string `json:"version"`
6+ Commit string `json:"commit"`
7+ GoVersion string `json:"go_version"`
8+ Platform string `json:"platform"`
9+}
internal/pubsub/events.go+31−5
@@ -1,6 +1,9 @@
11package pubsub
22
3−import "context"
3+import (
4+ "context"
5+ "encoding/json"
6+)
47
58const (
69 CreatedEvent EventType = "created"
@@ -8,20 +11,43 @@ const (
811 DeletedEvent EventType = "deleted"
912)
1013
14+// PayloadType identifies the type of event payload for discriminated
15+// deserialization over JSON.
16+type PayloadType = string
17+
18+const (
19+ PayloadTypeLSPEvent PayloadType = "lsp_event"
20+ PayloadTypeMCPEvent PayloadType = "mcp_event"
21+ PayloadTypePermissionRequest PayloadType = "permission_request"
22+ PayloadTypePermissionNotification PayloadType = "permission_notification"
23+ PayloadTypeMessage PayloadType = "message"
24+ PayloadTypeSession PayloadType = "session"
25+ PayloadTypeFile PayloadType = "file"
26+ PayloadTypeAgentEvent PayloadType = "agent_event"
27+)
28+
29+// Payload wraps a discriminated JSON payload with a type tag.
30+type Payload struct {
31+ Type PayloadType `json:"type"`
32+ Payload json.RawMessage `json:"payload"`
33+}
34+
35+// Subscriber can subscribe to events of type T.
1136type Subscriber[T any] interface {
1237 Subscribe(context.Context) <-chan Event[T]
1338}
1439
1540type (
16− // EventType identifies the type of event
41+ // EventType identifies the type of event.
1742 EventType string
1843
19− // Event represents an event in the lifecycle of a resource
44+ // Event represents an event in the lifecycle of a resource.
2045 Event[T any] struct {
21− Type EventType
22− Payload T
46+ Type EventType `json:"type"`
47+ Payload T `json:"payload"`
2348 }
2449
50+ // Publisher can publish events of type T.
2551 Publisher[T any] interface {
2652 Publish(EventType, T)
2753 }
internal/server/logging.gonew file+51
@@ -0,0 +1,51 @@
1+package server
2+
3+import (
4+ "log/slog"
5+ "net/http"
6+ "time"
7+)
8+
9+func (s *Server) loggingHandler(next http.Handler) http.Handler {
10+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
11+ if s.logger == nil {
12+ next.ServeHTTP(w, r)
13+ return
14+ }
15+
16+ start := time.Now()
17+ lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
18+ s.logger.Debug("HTTP request",
19+ slog.String("method", r.Method),
20+ slog.String("path", r.URL.Path),
21+ slog.String("remote_addr", r.RemoteAddr),
22+ slog.String("user_agent", r.UserAgent()),
23+ )
24+
25+ next.ServeHTTP(lrw, r)
26+ duration := time.Since(start)
27+
28+ s.logger.Debug("HTTP response",
29+ slog.String("method", r.Method),
30+ slog.String("path", r.URL.Path),
31+ slog.Int("status", lrw.statusCode),
32+ slog.Duration("duration", duration),
33+ slog.String("remote_addr", r.RemoteAddr),
34+ slog.String("user_agent", r.UserAgent()),
35+ )
36+ })
37+}
38+
39+type loggingResponseWriter struct {
40+ http.ResponseWriter
41+ statusCode int
42+}
43+
44+func (lrw *loggingResponseWriter) WriteHeader(code int) {
45+ lrw.statusCode = code
46+ lrw.ResponseWriter.WriteHeader(code)
47+}
48+
49+func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
50+ return lrw.ResponseWriter
51+}
internal/server/net_other.gonew file+10
@@ -0,0 +1,10 @@
1+//go:build !windows
2+// +build !windows
3+
4+package server
5+
6+import "net"
7+
8+func listen(network, address string) (net.Listener, error) {
9+ return net.Listen(network, address)
10+}
internal/server/net_windows.gonew file+24
@@ -0,0 +1,24 @@
1+//go:build windows
2+// +build windows
3+
4+package server
5+
6+import (
7+ "net"
8+
9+ "github.com/Microsoft/go-winio"
10+)
11+
12+func listen(network, address string) (net.Listener, error) {
13+ switch network {
14+ case "npipe":
15+ cfg := &winio.PipeConfig{
16+ MessageMode: true,
17+ InputBufferSize: 65536,
18+ OutputBufferSize: 65536,
19+ }
20+ return winio.ListenPipe(address, cfg)
21+ default:
22+ return net.Listen(network, address)
23+ }
24+}
internal/server/proto.gonew file+656
@@ -0,0 +1,656 @@
1+package server
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "log/slog"
8+ "net/http"
9+ "os"
10+ "path/filepath"
11+ "runtime"
12+
13+ "github.com/charmbracelet/crush/internal/app"
14+ "github.com/charmbracelet/crush/internal/config"
15+ "github.com/charmbracelet/crush/internal/db"
16+ "github.com/charmbracelet/crush/internal/permission"
17+ "github.com/charmbracelet/crush/internal/proto"
18+ "github.com/charmbracelet/crush/internal/session"
19+ "github.com/charmbracelet/crush/internal/version"
20+ "github.com/google/uuid"
21+)
22+
23+type controllerV1 struct {
24+ *Server
25+}
26+
27+func (c *controllerV1) handleGetHealth(w http.ResponseWriter, _ *http.Request) {
28+ w.WriteHeader(http.StatusOK)
29+}
30+
31+func (c *controllerV1) handleGetVersion(w http.ResponseWriter, _ *http.Request) {
32+ jsonEncode(w, proto.VersionInfo{
33+ Version: version.Version,
34+ Commit: version.Commit,
35+ GoVersion: runtime.Version(),
36+ Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
37+ })
38+}
39+
40+func (c *controllerV1) handlePostControl(w http.ResponseWriter, r *http.Request) {
41+ var req proto.ServerControl
42+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
43+ c.logError(r, "Failed to decode request", "error", err)
44+ jsonError(w, http.StatusBadRequest, "failed to decode request")
45+ return
46+ }
47+
48+ switch req.Command {
49+ case "shutdown":
50+ go func() {
51+ slog.Info("Shutting down server...")
52+ if err := c.Shutdown(context.Background()); err != nil {
53+ slog.Error("Failed to shutdown server", "error", err)
54+ }
55+ }()
56+ default:
57+ c.logError(r, "Unknown command", "command", req.Command)
58+ jsonError(w, http.StatusBadRequest, "unknown command")
59+ return
60+ }
61+}
62+
63+func (c *controllerV1) handleGetConfig(w http.ResponseWriter, _ *http.Request) {
64+ jsonEncode(w, c.cfg)
65+}
66+
67+func (c *controllerV1) handleGetWorkspaces(w http.ResponseWriter, _ *http.Request) {
68+ workspaces := []proto.Workspace{}
69+ for _, ws := range c.workspaces.Seq2() {
70+ workspaces = append(workspaces, proto.Workspace{
71+ ID: ws.id,
72+ Path: ws.path,
73+ YOLO: ws.cfg.Permissions != nil && ws.cfg.Permissions.SkipRequests,
74+ DataDir: ws.cfg.Options.DataDirectory,
75+ Debug: ws.cfg.Options.Debug,
76+ Config: ws.cfg,
77+ })
78+ }
79+ jsonEncode(w, workspaces)
80+}
81+
82+func (c *controllerV1) handleGetWorkspaceLSPDiagnostics(w http.ResponseWriter, r *http.Request) {
83+ id := r.PathValue("id")
84+ ws, ok := c.workspaces.Get(id)
85+ if !ok {
86+ c.logError(r, "Workspace not found", "id", id)
87+ jsonError(w, http.StatusNotFound, "workspace not found")
88+ return
89+ }
90+
91+ lspName := r.PathValue("lsp")
92+ var found bool
93+ for name, client := range ws.LSPManager.Clients().Seq2() {
94+ if name == lspName {
95+ diagnostics := client.GetDiagnostics()
96+ jsonEncode(w, diagnostics)
97+ found = true
98+ break
99+ }
100+ }
101+
102+ if !found {
103+ c.logError(r, "LSP client not found", "id", id, "lsp", lspName)
104+ jsonError(w, http.StatusNotFound, "LSP client not found")
105+ }
106+}
107+
108+func (c *controllerV1) handleGetWorkspaceLSPs(w http.ResponseWriter, r *http.Request) {
109+ id := r.PathValue("id")
110+ _, ok := c.workspaces.Get(id)
111+ if !ok {
112+ c.logError(r, "Workspace not found", "id", id)
113+ jsonError(w, http.StatusNotFound, "workspace not found")
114+ return
115+ }
116+
117+ lspClients := app.GetLSPStates()
118+ jsonEncode(w, lspClients)
119+}
120+
121+func (c *controllerV1) handleGetWorkspaceAgentSessionPromptQueued(w http.ResponseWriter, r *http.Request) {
122+ id := r.PathValue("id")
123+ ws, ok := c.workspaces.Get(id)
124+ if !ok {
125+ c.logError(r, "Workspace not found", "id", id)
126+ jsonError(w, http.StatusNotFound, "workspace not found")
127+ return
128+ }
129+
130+ sid := r.PathValue("sid")
131+ queued := ws.App.AgentCoordinator.QueuedPrompts(sid)
132+ jsonEncode(w, queued)
133+}
134+
135+func (c *controllerV1) handlePostWorkspaceAgentSessionPromptClear(w http.ResponseWriter, r *http.Request) {
136+ id := r.PathValue("id")
137+ ws, ok := c.workspaces.Get(id)
138+ if !ok {
139+ c.logError(r, "Workspace not found", "id", id)
140+ jsonError(w, http.StatusNotFound, "workspace not found")
141+ return
142+ }
143+
144+ sid := r.PathValue("sid")
145+ ws.App.AgentCoordinator.ClearQueue(sid)
146+ w.WriteHeader(http.StatusOK)
147+}
148+
149+func (c *controllerV1) handleGetWorkspaceAgentSessionSummarize(w http.ResponseWriter, r *http.Request) {
150+ id := r.PathValue("id")
151+ ws, ok := c.workspaces.Get(id)
152+ if !ok {
153+ c.logError(r, "Workspace not found", "id", id)
154+ jsonError(w, http.StatusNotFound, "workspace not found")
155+ return
156+ }
157+
158+ sid := r.PathValue("sid")
159+ if err := ws.App.AgentCoordinator.Summarize(r.Context(), sid); err != nil {
160+ c.logError(r, "Failed to summarize session", "error", err, "id", id, "sid", sid)
161+ jsonError(w, http.StatusInternalServerError, "failed to summarize session")
162+ return
163+ }
164+}
165+
166+func (c *controllerV1) handlePostWorkspaceAgentSessionCancel(w http.ResponseWriter, r *http.Request) {
167+ id := r.PathValue("id")
168+ ws, ok := c.workspaces.Get(id)
169+ if !ok {
170+ c.logError(r, "Workspace not found", "id", id)
171+ jsonError(w, http.StatusNotFound, "workspace not found")
172+ return
173+ }
174+
175+ sid := r.PathValue("sid")
176+ if ws.App.AgentCoordinator != nil {
177+ ws.App.AgentCoordinator.Cancel(sid)
178+ }
179+ w.WriteHeader(http.StatusOK)
180+}
181+
182+func (c *controllerV1) handleGetWorkspaceAgentSession(w http.ResponseWriter, r *http.Request) {
183+ id := r.PathValue("id")
184+ ws, ok := c.workspaces.Get(id)
185+ if !ok {
186+ c.logError(r, "Workspace not found", "id", id)
187+ jsonError(w, http.StatusNotFound, "workspace not found")
188+ return
189+ }
190+
191+ sid := r.PathValue("sid")
192+ se, err := ws.App.Sessions.Get(r.Context(), sid)
193+ if err != nil {
194+ c.logError(r, "Failed to get session", "error", err, "id", id, "sid", sid)
195+ jsonError(w, http.StatusInternalServerError, "failed to get session")
196+ return
197+ }
198+
199+ var isSessionBusy bool
200+ if ws.App.AgentCoordinator != nil {
201+ isSessionBusy = ws.App.AgentCoordinator.IsSessionBusy(sid)
202+ }
203+
204+ jsonEncode(w, proto.AgentSession{
205+ Session: proto.Session{
206+ ID: se.ID,
207+ Title: se.Title,
208+ },
209+ IsBusy: isSessionBusy,
210+ })
211+}
212+
213+func (c *controllerV1) handlePostWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
214+ id := r.PathValue("id")
215+ ws, ok := c.workspaces.Get(id)
216+ if !ok {
217+ c.logError(r, "Workspace not found", "id", id)
218+ jsonError(w, http.StatusNotFound, "workspace not found")
219+ return
220+ }
221+
222+ w.Header().Set("Accept", "application/json")
223+
224+ var msg proto.AgentMessage
225+ if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
226+ c.logError(r, "Failed to decode request", "error", err)
227+ jsonError(w, http.StatusBadRequest, "failed to decode request")
228+ return
229+ }
230+
231+ if ws.App.AgentCoordinator == nil {
232+ c.logError(r, "Agent coordinator not initialized", "id", id)
233+ jsonError(w, http.StatusBadRequest, "agent coordinator not initialized")
234+ return
235+ }
236+
237+ if _, err := ws.App.AgentCoordinator.Run(c.ctx, msg.SessionID, msg.Prompt); err != nil {
238+ c.logError(r, "Failed to enqueue message", "error", err, "id", id, "sid", msg.SessionID)
239+ jsonError(w, http.StatusInternalServerError, "failed to enqueue message")
240+ return
241+ }
242+}
243+
244+func (c *controllerV1) handleGetWorkspaceAgent(w http.ResponseWriter, r *http.Request) {
245+ id := r.PathValue("id")
246+ ws, ok := c.workspaces.Get(id)
247+ if !ok {
248+ c.logError(r, "Workspace not found", "id", id)
249+ jsonError(w, http.StatusNotFound, "workspace not found")
250+ return
251+ }
252+
253+ var agentInfo proto.AgentInfo
254+ if ws.App.AgentCoordinator != nil {
255+ m := ws.App.AgentCoordinator.Model()
256+ agentInfo = proto.AgentInfo{
257+ Model: m.CatwalkCfg,
258+ IsBusy: ws.App.AgentCoordinator.IsBusy(),
259+ }
260+ }
261+ jsonEncode(w, agentInfo)
262+}
263+
264+func (c *controllerV1) handlePostWorkspaceAgentUpdate(w http.ResponseWriter, r *http.Request) {
265+ id := r.PathValue("id")
266+ ws, ok := c.workspaces.Get(id)
267+ if !ok {
268+ c.logError(r, "Workspace not found", "id", id)
269+ jsonError(w, http.StatusNotFound, "workspace not found")
270+ return
271+ }
272+
273+ if err := ws.App.UpdateAgentModel(r.Context()); err != nil {
274+ c.logError(r, "Failed to update agent model", "error", err)
275+ jsonError(w, http.StatusInternalServerError, "failed to update agent model")
276+ return
277+ }
278+}
279+
280+func (c *controllerV1) handlePostWorkspaceAgentInit(w http.ResponseWriter, r *http.Request) {
281+ id := r.PathValue("id")
282+ ws, ok := c.workspaces.Get(id)
283+ if !ok {
284+ c.logError(r, "Workspace not found", "id", id)
285+ jsonError(w, http.StatusNotFound, "workspace not found")
286+ return
287+ }
288+
289+ if err := ws.App.InitCoderAgent(r.Context()); err != nil {
290+ c.logError(r, "Failed to initialize coder agent", "error", err)
291+ jsonError(w, http.StatusInternalServerError, "failed to initialize coder agent")
292+ return
293+ }
294+}
295+
296+func (c *controllerV1) handleGetWorkspaceSessionHistory(w http.ResponseWriter, r *http.Request) {
297+ id := r.PathValue("id")
298+ ws, ok := c.workspaces.Get(id)
299+ if !ok {
300+ c.logError(r, "Workspace not found", "id", id)
301+ jsonError(w, http.StatusNotFound, "workspace not found")
302+ return
303+ }
304+
305+ sid := r.PathValue("sid")
306+ historyItems, err := ws.App.History.ListBySession(r.Context(), sid)
307+ if err != nil {
308+ c.logError(r, "Failed to list history", "error", err, "id", id, "sid", sid)
309+ jsonError(w, http.StatusInternalServerError, "failed to list history")
310+ return
311+ }
312+
313+ jsonEncode(w, historyItems)
314+}
315+
316+func (c *controllerV1) handleGetWorkspaceSessionMessages(w http.ResponseWriter, r *http.Request) {
317+ id := r.PathValue("id")
318+ ws, ok := c.workspaces.Get(id)
319+ if !ok {
320+ c.logError(r, "Workspace not found", "id", id)
321+ jsonError(w, http.StatusNotFound, "workspace not found")
322+ return
323+ }
324+
325+ sid := r.PathValue("sid")
326+ messages, err := ws.App.Messages.List(r.Context(), sid)
327+ if err != nil {
328+ c.logError(r, "Failed to list messages", "error", err, "id", id, "sid", sid)
329+ jsonError(w, http.StatusInternalServerError, "failed to list messages")
330+ return
331+ }
332+
333+ jsonEncode(w, messages)
334+}
335+
336+func (c *controllerV1) handleGetWorkspaceSession(w http.ResponseWriter, r *http.Request) {
337+ id := r.PathValue("id")
338+ ws, ok := c.workspaces.Get(id)
339+ if !ok {
340+ c.logError(r, "Workspace not found", "id", id)
341+ jsonError(w, http.StatusNotFound, "workspace not found")
342+ return
343+ }
344+
345+ sid := r.PathValue("sid")
346+ sess, err := ws.App.Sessions.Get(r.Context(), sid)
347+ if err != nil {
348+ c.logError(r, "Failed to get session", "error", err, "id", id, "sid", sid)
349+ jsonError(w, http.StatusInternalServerError, "failed to get session")
350+ return
351+ }
352+
353+ jsonEncode(w, sess)
354+}
355+
356+func (c *controllerV1) handlePostWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
357+ id := r.PathValue("id")
358+ ws, ok := c.workspaces.Get(id)
359+ if !ok {
360+ c.logError(r, "Workspace not found", "id", id)
361+ jsonError(w, http.StatusNotFound, "workspace not found")
362+ return
363+ }
364+
365+ var args session.Session
366+ if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
367+ c.logError(r, "Failed to decode request", "error", err)
368+ jsonError(w, http.StatusBadRequest, "failed to decode request")
369+ return
370+ }
371+
372+ sess, err := ws.App.Sessions.Create(r.Context(), args.Title)
373+ if err != nil {
374+ c.logError(r, "Failed to create session", "error", err, "id", id)
375+ jsonError(w, http.StatusInternalServerError, "failed to create session")
376+ return
377+ }
378+
379+ jsonEncode(w, sess)
380+}
381+
382+func (c *controllerV1) handleGetWorkspaceSessions(w http.ResponseWriter, r *http.Request) {
383+ id := r.PathValue("id")
384+ ws, ok := c.workspaces.Get(id)
385+ if !ok {
386+ c.logError(r, "Workspace not found", "id", id)
387+ jsonError(w, http.StatusNotFound, "workspace not found")
388+ return
389+ }
390+
391+ sessions, err := ws.App.Sessions.List(r.Context())
392+ if err != nil {
393+ c.logError(r, "Failed to list sessions", "error", err)
394+ jsonError(w, http.StatusInternalServerError, "failed to list sessions")
395+ return
396+ }
397+
398+ jsonEncode(w, sessions)
399+}
400+
401+func (c *controllerV1) handlePostWorkspacePermissionsGrant(w http.ResponseWriter, r *http.Request) {
402+ id := r.PathValue("id")
403+ ws, ok := c.workspaces.Get(id)
404+ if !ok {
405+ c.logError(r, "Workspace not found", "id", id)
406+ jsonError(w, http.StatusNotFound, "workspace not found")
407+ return
408+ }
409+
410+ var req proto.PermissionGrant
411+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
412+ c.logError(r, "Failed to decode request", "error", err)
413+ jsonError(w, http.StatusBadRequest, "failed to decode request")
414+ return
415+ }
416+
417+ perm := permission.PermissionRequest{
418+ ID: req.Permission.ID,
419+ SessionID: req.Permission.SessionID,
420+ ToolCallID: req.Permission.ToolCallID,
421+ ToolName: req.Permission.ToolName,
422+ Description: req.Permission.Description,
423+ Action: req.Permission.Action,
424+ Params: req.Permission.Params,
425+ Path: req.Permission.Path,
426+ }
427+
428+ switch req.Action {
429+ case proto.PermissionAllow:
430+ ws.App.Permissions.Grant(perm)
431+ case proto.PermissionAllowForSession:
432+ ws.App.Permissions.GrantPersistent(perm)
433+ case proto.PermissionDeny:
434+ ws.App.Permissions.Deny(perm)
435+ default:
436+ c.logError(r, "Invalid permission action", "action", req.Action)
437+ jsonError(w, http.StatusBadRequest, "invalid permission action")
438+ return
439+ }
440+}
441+
442+func (c *controllerV1) handlePostWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
443+ id := r.PathValue("id")
444+ ws, ok := c.workspaces.Get(id)
445+ if !ok {
446+ c.logError(r, "Workspace not found", "id", id)
447+ jsonError(w, http.StatusNotFound, "workspace not found")
448+ return
449+ }
450+
451+ var req proto.PermissionSkipRequest
452+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
453+ c.logError(r, "Failed to decode request", "error", err)
454+ jsonError(w, http.StatusBadRequest, "failed to decode request")
455+ return
456+ }
457+
458+ ws.App.Permissions.SetSkipRequests(req.Skip)
459+}
460+
461+func (c *controllerV1) handleGetWorkspacePermissionsSkip(w http.ResponseWriter, r *http.Request) {
462+ id := r.PathValue("id")
463+ ws, ok := c.workspaces.Get(id)
464+ if !ok {
465+ c.logError(r, "Workspace not found", "id", id)
466+ jsonError(w, http.StatusNotFound, "workspace not found")
467+ return
468+ }
469+
470+ skip := ws.App.Permissions.SkipRequests()
471+ jsonEncode(w, proto.PermissionSkipRequest{Skip: skip})
472+}
473+
474+func (c *controllerV1) handleGetWorkspaceProviders(w http.ResponseWriter, r *http.Request) {
475+ id := r.PathValue("id")
476+ ws, ok := c.workspaces.Get(id)
477+ if !ok {
478+ c.logError(r, "Workspace not found", "id", id)
479+ jsonError(w, http.StatusNotFound, "workspace not found")
480+ return
481+ }
482+
483+ providers, _ := config.Providers(ws.cfg)
484+ jsonEncode(w, providers)
485+}
486+
487+func (c *controllerV1) handleGetWorkspaceEvents(w http.ResponseWriter, r *http.Request) {
488+ flusher := http.NewResponseController(w)
489+ id := r.PathValue("id")
490+ ws, ok := c.workspaces.Get(id)
491+ if !ok {
492+ c.logError(r, "Workspace not found", "id", id)
493+ jsonError(w, http.StatusNotFound, "workspace not found")
494+ return
495+ }
496+
497+ w.Header().Set("Content-Type", "text/event-stream")
498+ w.Header().Set("Cache-Control", "no-cache")
499+ w.Header().Set("Connection", "keep-alive")
500+
501+ events := ws.App.Events()
502+
503+ for {
504+ select {
505+ case <-r.Context().Done():
506+ c.logDebug(r, "Stopping event stream")
507+ return
508+ case ev, ok := <-events:
509+ if !ok {
510+ return
511+ }
512+ c.logDebug(r, "Sending event", "event", fmt.Sprintf("%T %+v", ev, ev))
513+ data, err := json.Marshal(ev)
514+ if err != nil {
515+ c.logError(r, "Failed to marshal event", "error", err)
516+ continue
517+ }
518+
519+ fmt.Fprintf(w, "data: %s\n\n", data)
520+ flusher.Flush()
521+ }
522+ }
523+}
524+
525+func (c *controllerV1) handleGetWorkspaceConfig(w http.ResponseWriter, r *http.Request) {
526+ id := r.PathValue("id")
527+ ws, ok := c.workspaces.Get(id)
528+ if !ok {
529+ c.logError(r, "Workspace not found", "id", id)
530+ jsonError(w, http.StatusNotFound, "workspace not found")
531+ return
532+ }
533+
534+ jsonEncode(w, ws.cfg)
535+}
536+
537+func (c *controllerV1) handleDeleteWorkspaces(w http.ResponseWriter, r *http.Request) {
538+ id := r.PathValue("id")
539+ ws, ok := c.workspaces.Get(id)
540+ if ok {
541+ ws.App.Shutdown()
542+ }
543+ c.workspaces.Del(id)
544+}
545+
546+func (c *controllerV1) handleGetWorkspace(w http.ResponseWriter, r *http.Request) {
547+ id := r.PathValue("id")
548+ ws, ok := c.workspaces.Get(id)
549+ if !ok {
550+ c.logError(r, "Workspace not found", "id", id)
551+ jsonError(w, http.StatusNotFound, "workspace not found")
552+ return
553+ }
554+
555+ jsonEncode(w, proto.Workspace{
556+ ID: ws.id,
557+ Path: ws.path,
558+ YOLO: ws.cfg.Permissions != nil && ws.cfg.Permissions.SkipRequests,
559+ DataDir: ws.cfg.Options.DataDirectory,
560+ Debug: ws.cfg.Options.Debug,
561+ Config: ws.cfg,
562+ })
563+}
564+
565+func (c *controllerV1) handlePostWorkspaces(w http.ResponseWriter, r *http.Request) {
566+ var args proto.Workspace
567+ if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
568+ c.logError(r, "Failed to decode request", "error", err)
569+ jsonError(w, http.StatusBadRequest, "failed to decode request")
570+ return
571+ }
572+
573+ if args.Path == "" {
574+ c.logError(r, "Path is required")
575+ jsonError(w, http.StatusBadRequest, "path is required")
576+ return
577+ }
578+
579+ id := uuid.New().String()
580+ cfg, err := config.Init(args.Path, args.DataDir, args.Debug)
581+ if err != nil {
582+ c.logError(r, "Failed to initialize config", "error", err)
583+ jsonError(w, http.StatusBadRequest, fmt.Sprintf("failed to initialize config: %v", err))
584+ return
585+ }
586+
587+ if cfg.Permissions == nil {
588+ cfg.Permissions = &config.Permissions{}
589+ }
590+ cfg.Permissions.SkipRequests = args.YOLO
591+
592+ if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
593+ c.logError(r, "Failed to create data directory", "error", err)
594+ jsonError(w, http.StatusInternalServerError, "failed to create data directory")
595+ return
596+ }
597+
598+ conn, err := db.Connect(c.ctx, cfg.Options.DataDirectory)
599+ if err != nil {
600+ c.logError(r, "Failed to connect to database", "error", err)
601+ jsonError(w, http.StatusInternalServerError, "failed to connect to database")
602+ return
603+ }
604+
605+ appWorkspace, err := app.New(c.ctx, conn, cfg)
606+ if err != nil {
607+ slog.Error("Failed to create app workspace", "error", err)
608+ jsonError(w, http.StatusInternalServerError, "failed to create app workspace")
609+ return
610+ }
611+
612+ ws := &Workspace{
613+ App: appWorkspace,
614+ id: id,
615+ path: args.Path,
616+ cfg: cfg,
617+ env: args.Env,
618+ }
619+
620+ c.workspaces.Set(id, ws)
621+ jsonEncode(w, proto.Workspace{
622+ ID: id,
623+ Path: args.Path,
624+ DataDir: cfg.Options.DataDirectory,
625+ Debug: cfg.Options.Debug,
626+ YOLO: cfg.Permissions.SkipRequests,
627+ Config: cfg,
628+ Env: args.Env,
629+ })
630+}
631+
632+func createDotCrushDir(dir string) error {
633+ if err := os.MkdirAll(dir, 0o700); err != nil {
634+ return fmt.Errorf("failed to create data directory: %q %w", dir, err)
635+ }
636+
637+ gitIgnorePath := filepath.Join(dir, ".gitignore")
638+ if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
639+ if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
640+ return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
641+ }
642+ }
643+
644+ return nil
645+}
646+
647+func jsonEncode(w http.ResponseWriter, v any) {
648+ w.Header().Set("Content-Type", "application/json")
649+ _ = json.NewEncoder(w).Encode(v)
650+}
651+
652+func jsonError(w http.ResponseWriter, status int, message string) {
653+ w.Header().Set("Content-Type", "application/json")
654+ w.WriteHeader(status)
655+ _ = json.NewEncoder(w).Encode(proto.Error{Message: message})
656+}
internal/server/server.gonew file+208
@@ -0,0 +1,208 @@
1+package server
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+ "net"
8+ "net/http"
9+ "net/url"
10+ "os/user"
11+ "runtime"
12+ "strings"
13+
14+ "github.com/charmbracelet/crush/internal/app"
15+ "github.com/charmbracelet/crush/internal/config"
16+ "github.com/charmbracelet/crush/internal/csync"
17+)
18+
19+// ErrServerClosed is returned when the server is closed.
20+var ErrServerClosed = http.ErrServerClosed
21+
22+// Workspace represents a running [app.App] workspace with its associated
23+// resources and state.
24+type Workspace struct {
25+ *app.App
26+ ln net.Listener
27+ cfg *config.Config
28+ id string
29+ path string
30+ env []string
31+}
32+
33+// ParseHostURL parses a host URL into a [url.URL].
34+func ParseHostURL(host string) (*url.URL, error) {
35+ proto, addr, ok := strings.Cut(host, "://")
36+ if !ok {
37+ return nil, fmt.Errorf("invalid host format: %s", host)
38+ }
39+
40+ var basePath string
41+ if proto == "tcp" {
42+ parsed, err := url.Parse("tcp://" + addr)
43+ if err != nil {
44+ return nil, fmt.Errorf("invalid tcp address: %v", err)
45+ }
46+ addr = parsed.Host
47+ basePath = parsed.Path
48+ }
49+ return &url.URL{
50+ Scheme: proto,
51+ Host: addr,
52+ Path: basePath,
53+ }, nil
54+}
55+
56+// DefaultHost returns the default server host.
57+func DefaultHost() string {
58+ sock := "crush.sock"
59+ usr, err := user.Current()
60+ if err == nil && usr.Uid != "" {
61+ sock = fmt.Sprintf("crush-%s.sock", usr.Uid)
62+ }
63+ if runtime.GOOS == "windows" {
64+ return fmt.Sprintf("npipe:////./pipe/%s", sock)
65+ }
66+ return fmt.Sprintf("unix:///tmp/%s", sock)
67+}
68+
69+// Server represents a Crush server bound to a specific address.
70+type Server struct {
71+ // Addr can be a TCP address, a Unix socket path, or a Windows named pipe.
72+ Addr string
73+ network string
74+
75+ h *http.Server
76+ ln net.Listener
77+ ctx context.Context
78+
79+ // workspaces is a map of running applications managed by the server.
80+ workspaces *csync.Map[string, *Workspace]
81+ cfg *config.Config
82+ logger *slog.Logger
83+}
84+
85+// SetLogger sets the logger for the server.
86+func (s *Server) SetLogger(logger *slog.Logger) {
87+ s.logger = logger
88+}
89+
90+// DefaultServer returns a new [Server] with the default address.
91+func DefaultServer(cfg *config.Config) *Server {
92+ hostURL, err := ParseHostURL(DefaultHost())
93+ if err != nil {
94+ panic("invalid default host")
95+ }
96+ return NewServer(cfg, hostURL.Scheme, hostURL.Host)
97+}
98+
99+// NewServer creates a new [Server] with the given network and address.
100+func NewServer(cfg *config.Config, network, address string) *Server {
101+ s := new(Server)
102+ s.Addr = address
103+ s.network = network
104+ s.cfg = cfg
105+ s.workspaces = csync.NewMap[string, *Workspace]()
106+ s.ctx = context.Background()
107+
108+ var p http.Protocols
109+ p.SetHTTP1(true)
110+ p.SetUnencryptedHTTP2(true)
111+ c := &controllerV1{Server: s}
112+ mux := http.NewServeMux()
113+ mux.HandleFunc("GET /v1/health", c.handleGetHealth)
114+ mux.HandleFunc("GET /v1/version", c.handleGetVersion)
115+ mux.HandleFunc("GET /v1/config", c.handleGetConfig)
116+ mux.HandleFunc("POST /v1/control", c.handlePostControl)
117+ mux.HandleFunc("GET /v1/workspaces", c.handleGetWorkspaces)
118+ mux.HandleFunc("POST /v1/workspaces", c.handlePostWorkspaces)
119+ mux.HandleFunc("DELETE /v1/workspaces/{id}", c.handleDeleteWorkspaces)
120+ mux.HandleFunc("GET /v1/workspaces/{id}", c.handleGetWorkspace)
121+ mux.HandleFunc("GET /v1/workspaces/{id}/config", c.handleGetWorkspaceConfig)
122+ mux.HandleFunc("GET /v1/workspaces/{id}/events", c.handleGetWorkspaceEvents)
123+ mux.HandleFunc("GET /v1/workspaces/{id}/providers", c.handleGetWorkspaceProviders)
124+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions", c.handleGetWorkspaceSessions)
125+ mux.HandleFunc("POST /v1/workspaces/{id}/sessions", c.handlePostWorkspaceSessions)
126+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}", c.handleGetWorkspaceSession)
127+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/history", c.handleGetWorkspaceSessionHistory)
128+ mux.HandleFunc("GET /v1/workspaces/{id}/sessions/{sid}/messages", c.handleGetWorkspaceSessionMessages)
129+ mux.HandleFunc("GET /v1/workspaces/{id}/lsps", c.handleGetWorkspaceLSPs)
130+ mux.HandleFunc("GET /v1/workspaces/{id}/lsps/{lsp}/diagnostics", c.handleGetWorkspaceLSPDiagnostics)
131+ mux.HandleFunc("GET /v1/workspaces/{id}/permissions/skip", c.handleGetWorkspacePermissionsSkip)
132+ mux.HandleFunc("POST /v1/workspaces/{id}/permissions/skip", c.handlePostWorkspacePermissionsSkip)
133+ mux.HandleFunc("POST /v1/workspaces/{id}/permissions/grant", c.handlePostWorkspacePermissionsGrant)
134+ mux.HandleFunc("GET /v1/workspaces/{id}/agent", c.handleGetWorkspaceAgent)
135+ mux.HandleFunc("POST /v1/workspaces/{id}/agent", c.handlePostWorkspaceAgent)
136+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/init", c.handlePostWorkspaceAgentInit)
137+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/update", c.handlePostWorkspaceAgentUpdate)
138+ mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}", c.handleGetWorkspaceAgentSession)
139+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/cancel", c.handlePostWorkspaceAgentSessionCancel)
140+ mux.HandleFunc("GET /v1/workspaces/{id}/agent/sessions/{sid}/prompts/queued", c.handleGetWorkspaceAgentSessionPromptQueued)
141+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/prompts/clear", c.handlePostWorkspaceAgentSessionPromptClear)
142+ mux.HandleFunc("POST /v1/workspaces/{id}/agent/sessions/{sid}/summarize", c.handleGetWorkspaceAgentSessionSummarize)
143+ s.h = &http.Server{
144+ Protocols: &p,
145+ Handler: s.loggingHandler(mux),
146+ }
147+ if network == "tcp" {
148+ s.h.Addr = address
149+ }
150+ return s
151+}
152+
153+// Serve accepts incoming connections on the listener.
154+func (s *Server) Serve(ln net.Listener) error {
155+ return s.h.Serve(ln)
156+}
157+
158+// ListenAndServe starts the server and begins accepting connections.
159+func (s *Server) ListenAndServe() error {
160+ if s.ln != nil {
161+ return fmt.Errorf("server already started")
162+ }
163+ ln, err := listen(s.network, s.Addr)
164+ if err != nil {
165+ return fmt.Errorf("failed to listen on %s: %w", s.Addr, err)
166+ }
167+ return s.Serve(ln)
168+}
169+
170+func (s *Server) closeListener() {
171+ if s.ln != nil {
172+ s.ln.Close()
173+ s.ln = nil
174+ }
175+}
176+
177+// Close force closes all listeners and connections.
178+func (s *Server) Close() error {
179+ defer func() { s.closeListener() }()
180+ return s.h.Close()
181+}
182+
183+// Shutdown gracefully shuts down the server without interrupting active
184+// connections.
185+func (s *Server) Shutdown(ctx context.Context) error {
186+ defer func() { s.closeListener() }()
187+ return s.h.Shutdown(ctx)
188+}
189+
190+func (s *Server) logDebug(r *http.Request, msg string, args ...any) {
191+ if s.logger != nil {
192+ s.logger.With(
193+ slog.String("method", r.Method),
194+ slog.String("url", r.URL.String()),
195+ slog.String("remote_addr", r.RemoteAddr),
196+ ).Debug(msg, args...)
197+ }
198+}
199+
200+func (s *Server) logError(r *http.Request, msg string, args ...any) {
201+ if s.logger != nil {
202+ s.logger.With(
203+ slog.String("method", r.Method),
204+ slog.String("url", r.URL.String()),
205+ slog.String("remote_addr", r.RemoteAddr),
206+ ).Error(msg, args...)
207+ }
208+}
internal/ui/model/onboarding.go+4−1
@@ -57,7 +57,10 @@ func (m *UI) initializeProject() tea.Cmd {
5757 initialize := func() tea.Msg {
5858 initPrompt, err := agent.InitializePrompt(*cfg)
5959 if err != nil {
60− return util.InfoMsg{Type: util.InfoTypeError, Msg: err.Error()}
60+ return util.InfoMsg{
61+ Type: util.InfoTypeError,
62+ Msg: fmt.Sprintf("Failed to initialize project: %v", err),
63+ }
6164 }
6265 return sendMessageMsg{Content: initPrompt}
6366 }
internal/ui/model/ui.go+4−1
@@ -740,6 +740,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
740740 cmds = append(cmds, cmd)
741741 }
742742 case util.InfoMsg:
743+ if msg.Type == util.InfoTypeError {
744+ slog.Error("Error reported", "error", msg.Msg)
745+ }
743746 m.status.SetInfoMsg(msg)
744747 ttl := msg.TTL
745748 if ttl <= 0 {
@@ -2753,7 +2756,7 @@ func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.
27532756 }
27542757 return util.InfoMsg{
27552758 Type: util.InfoTypeError,
2756− Msg: err.Error(),
2759+ Msg: fmt.Sprintf("Failed to run agent: %v", err),
27572760 }
27582761 }
27592762 return nil
internal/ui/util/util.go−2
@@ -4,7 +4,6 @@ package util
44import (
55 "context"
66 "errors"
7− "log/slog"
87 "os/exec"
98 "time"
109
@@ -23,7 +22,6 @@ func CmdHandler(msg tea.Msg) tea.Cmd {
2322}
2423
2524func ReportError(err error) tea.Cmd {
26− slog.Error("Error reported", "error", err)
2725 return CmdHandler(NewErrorMsg(err))
2826}
2927
internal/version/version.go+5−2
@@ -2,9 +2,12 @@ package version
22
33import "runtime/debug"
44
5−// Build-time parameters set via -ldflags
5+// Build-time parameters set via -ldflags.
66
7−var Version = "devel"
7+var (
8+ Version = "devel"
9+ Commit = "unknown"
10+)
811
912// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
1013// without -ldflags, in which case the version above is unset. As a workaround