proto.go

  1package client
  2
  3import (
  4	"bufio"
  5	"bytes"
  6	"context"
  7	"encoding/json"
  8	"errors"
  9	"fmt"
 10	"io"
 11	"log/slog"
 12	"net/http"
 13	"net/url"
 14	"time"
 15
 16	"github.com/charmbracelet/crush/internal/config"
 17	"github.com/charmbracelet/crush/internal/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.
 26func (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// GetWorkspace retrieves a workspace from the server.
 43func (c *Client) GetWorkspace(ctx context.Context, id string) (*proto.Workspace, error) {
 44	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s", id), nil, nil)
 45	if err != nil {
 46		return nil, fmt.Errorf("failed to get workspace: %w", err)
 47	}
 48	defer rsp.Body.Close()
 49	if rsp.StatusCode != http.StatusOK {
 50		return nil, fmt.Errorf("failed to get workspace: status code %d", rsp.StatusCode)
 51	}
 52	var ws proto.Workspace
 53	if err := json.NewDecoder(rsp.Body).Decode(&ws); err != nil {
 54		return nil, fmt.Errorf("failed to decode workspace: %w", err)
 55	}
 56	return &ws, nil
 57}
 58
 59// DeleteWorkspace deletes a workspace on the server.
 60func (c *Client) DeleteWorkspace(ctx context.Context, id string) error {
 61	rsp, err := c.delete(ctx, fmt.Sprintf("/workspaces/%s", id), nil, nil)
 62	if err != nil {
 63		return fmt.Errorf("failed to delete workspace: %w", err)
 64	}
 65	defer rsp.Body.Close()
 66	if rsp.StatusCode != http.StatusOK {
 67		return fmt.Errorf("failed to delete workspace: status code %d", rsp.StatusCode)
 68	}
 69	return nil
 70}
 71
 72// SubscribeEvents subscribes to server-sent events for a workspace.
 73func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, error) {
 74	events := make(chan any, 100)
 75	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/events", id), nil, http.Header{
 76		"Accept":        []string{"text/event-stream"},
 77		"Cache-Control": []string{"no-cache"},
 78		"Connection":    []string{"keep-alive"},
 79	})
 80	if err != nil {
 81		return nil, fmt.Errorf("failed to subscribe to events: %w", err)
 82	}
 83
 84	if rsp.StatusCode != http.StatusOK {
 85		return nil, fmt.Errorf("failed to subscribe to events: status code %d", rsp.StatusCode)
 86	}
 87
 88	go func() {
 89		defer rsp.Body.Close()
 90
 91		scr := bufio.NewReader(rsp.Body)
 92		for {
 93			line, err := scr.ReadBytes('\n')
 94			if errors.Is(err, io.EOF) {
 95				break
 96			}
 97			if err != nil {
 98				slog.Error("Reading from events stream", "error", err)
 99				time.Sleep(time.Second * 2)
100				continue
101			}
102			line = bytes.TrimSpace(line)
103			if len(line) == 0 {
104				continue
105			}
106
107			data, ok := bytes.CutPrefix(line, []byte("data:"))
108			if !ok {
109				slog.Warn("Invalid event format", "line", string(line))
110				continue
111			}
112
113			data = bytes.TrimSpace(data)
114
115			var p pubsub.Payload
116			if err := json.Unmarshal(data, &p); err != nil {
117				slog.Error("Unmarshaling event envelope", "error", err)
118				continue
119			}
120
121			switch p.Type {
122			case pubsub.PayloadTypeLSPEvent:
123				var e pubsub.Event[proto.LSPEvent]
124				_ = json.Unmarshal(p.Payload, &e)
125				sendEvent(ctx, events, e)
126			case pubsub.PayloadTypeMCPEvent:
127				var e pubsub.Event[proto.MCPEvent]
128				_ = json.Unmarshal(p.Payload, &e)
129				sendEvent(ctx, events, e)
130			case pubsub.PayloadTypePermissionRequest:
131				var e pubsub.Event[proto.PermissionRequest]
132				_ = json.Unmarshal(p.Payload, &e)
133				sendEvent(ctx, events, e)
134			case pubsub.PayloadTypePermissionNotification:
135				var e pubsub.Event[proto.PermissionNotification]
136				_ = json.Unmarshal(p.Payload, &e)
137				sendEvent(ctx, events, e)
138			case pubsub.PayloadTypeMessage:
139				var e pubsub.Event[proto.Message]
140				_ = json.Unmarshal(p.Payload, &e)
141				sendEvent(ctx, events, e)
142			case pubsub.PayloadTypeSession:
143				var e pubsub.Event[proto.Session]
144				_ = json.Unmarshal(p.Payload, &e)
145				sendEvent(ctx, events, e)
146			case pubsub.PayloadTypeFile:
147				var e pubsub.Event[proto.File]
148				_ = json.Unmarshal(p.Payload, &e)
149				sendEvent(ctx, events, e)
150			case pubsub.PayloadTypeAgentEvent:
151				var e pubsub.Event[proto.AgentEvent]
152				_ = json.Unmarshal(p.Payload, &e)
153				sendEvent(ctx, events, e)
154			default:
155				slog.Warn("Unknown event type", "type", p.Type)
156				continue
157			}
158		}
159	}()
160
161	return events, nil
162}
163
164func sendEvent(ctx context.Context, evc chan any, ev any) {
165	slog.Info("Event received", "event", fmt.Sprintf("%T %+v", ev, ev))
166	select {
167	case evc <- ev:
168	case <-ctx.Done():
169		close(evc)
170		return
171	}
172}
173
174// GetLSPDiagnostics retrieves LSP diagnostics for a specific LSP client.
175func (c *Client) GetLSPDiagnostics(ctx context.Context, id string, lspName string) (map[protocol.DocumentURI][]protocol.Diagnostic, error) {
176	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/lsps/%s/diagnostics", id, lspName), nil, nil)
177	if err != nil {
178		return nil, fmt.Errorf("failed to get LSP diagnostics: %w", err)
179	}
180	defer rsp.Body.Close()
181	if rsp.StatusCode != http.StatusOK {
182		return nil, fmt.Errorf("failed to get LSP diagnostics: status code %d", rsp.StatusCode)
183	}
184	var diagnostics map[protocol.DocumentURI][]protocol.Diagnostic
185	if err := json.NewDecoder(rsp.Body).Decode(&diagnostics); err != nil {
186		return nil, fmt.Errorf("failed to decode LSP diagnostics: %w", err)
187	}
188	return diagnostics, nil
189}
190
191// GetLSPs retrieves the LSP client states for a workspace.
192func (c *Client) GetLSPs(ctx context.Context, id string) (map[string]proto.LSPClientInfo, error) {
193	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/lsps", id), nil, nil)
194	if err != nil {
195		return nil, fmt.Errorf("failed to get LSPs: %w", err)
196	}
197	defer rsp.Body.Close()
198	if rsp.StatusCode != http.StatusOK {
199		return nil, fmt.Errorf("failed to get LSPs: status code %d", rsp.StatusCode)
200	}
201	var lsps map[string]proto.LSPClientInfo
202	if err := json.NewDecoder(rsp.Body).Decode(&lsps); err != nil {
203		return nil, fmt.Errorf("failed to decode LSPs: %w", err)
204	}
205	return lsps, nil
206}
207
208// MCPGetStates retrieves the MCP client states for a workspace.
209func (c *Client) MCPGetStates(ctx context.Context, id string) (map[string]proto.MCPClientInfo, error) {
210	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/mcp/states", id), nil, nil)
211	if err != nil {
212		return nil, fmt.Errorf("failed to get MCP states: %w", err)
213	}
214	defer rsp.Body.Close()
215	if rsp.StatusCode != http.StatusOK {
216		return nil, fmt.Errorf("failed to get MCP states: status code %d", rsp.StatusCode)
217	}
218	var states map[string]proto.MCPClientInfo
219	if err := json.NewDecoder(rsp.Body).Decode(&states); err != nil {
220		return nil, fmt.Errorf("failed to decode MCP states: %w", err)
221	}
222	return states, nil
223}
224
225// MCPRefreshPrompts refreshes prompts for a named MCP client.
226func (c *Client) MCPRefreshPrompts(ctx context.Context, id, name string) error {
227	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-prompts", id), nil,
228		jsonBody(struct {
229			Name string `json:"name"`
230		}{Name: name}),
231		http.Header{"Content-Type": []string{"application/json"}})
232	if err != nil {
233		return fmt.Errorf("failed to refresh MCP prompts: %w", err)
234	}
235	defer rsp.Body.Close()
236	if rsp.StatusCode != http.StatusOK {
237		return fmt.Errorf("failed to refresh MCP prompts: status code %d", rsp.StatusCode)
238	}
239	return nil
240}
241
242// MCPRefreshResources refreshes resources for a named MCP client.
243func (c *Client) MCPRefreshResources(ctx context.Context, id, name string) error {
244	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/mcp/refresh-resources", id), nil,
245		jsonBody(struct {
246			Name string `json:"name"`
247		}{Name: name}),
248		http.Header{"Content-Type": []string{"application/json"}})
249	if err != nil {
250		return fmt.Errorf("failed to refresh MCP resources: %w", err)
251	}
252	defer rsp.Body.Close()
253	if rsp.StatusCode != http.StatusOK {
254		return fmt.Errorf("failed to refresh MCP resources: status code %d", rsp.StatusCode)
255	}
256	return nil
257}
258
259// GetAgentSessionQueuedPrompts retrieves the number of queued prompts for a
260// session.
261func (c *Client) GetAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) (int, error) {
262	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/queued", id, sessionID), nil, nil)
263	if err != nil {
264		return 0, fmt.Errorf("failed to get session agent queued prompts: %w", err)
265	}
266	defer rsp.Body.Close()
267	if rsp.StatusCode != http.StatusOK {
268		return 0, fmt.Errorf("failed to get session agent queued prompts: status code %d", rsp.StatusCode)
269	}
270	var count int
271	if err := json.NewDecoder(rsp.Body).Decode(&count); err != nil {
272		return 0, fmt.Errorf("failed to decode session agent queued prompts: %w", err)
273	}
274	return count, nil
275}
276
277// ClearAgentSessionQueuedPrompts clears the queued prompts for a session.
278func (c *Client) ClearAgentSessionQueuedPrompts(ctx context.Context, id string, sessionID string) error {
279	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/clear", id, sessionID), nil, nil, nil)
280	if err != nil {
281		return fmt.Errorf("failed to clear session agent queued prompts: %w", err)
282	}
283	defer rsp.Body.Close()
284	if rsp.StatusCode != http.StatusOK {
285		return fmt.Errorf("failed to clear session agent queued prompts: status code %d", rsp.StatusCode)
286	}
287	return nil
288}
289
290// GetAgentInfo retrieves the agent status for a workspace.
291func (c *Client) GetAgentInfo(ctx context.Context, id string) (*proto.AgentInfo, error) {
292	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent", id), nil, nil)
293	if err != nil {
294		return nil, fmt.Errorf("failed to get agent status: %w", err)
295	}
296	defer rsp.Body.Close()
297	if rsp.StatusCode != http.StatusOK {
298		return nil, fmt.Errorf("failed to get agent status: status code %d", rsp.StatusCode)
299	}
300	var info proto.AgentInfo
301	if err := json.NewDecoder(rsp.Body).Decode(&info); err != nil {
302		return nil, fmt.Errorf("failed to decode agent status: %w", err)
303	}
304	return &info, nil
305}
306
307// UpdateAgent triggers an agent model update on the server.
308func (c *Client) UpdateAgent(ctx context.Context, id string) error {
309	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/update", id), nil, nil, nil)
310	if err != nil {
311		return fmt.Errorf("failed to update agent: %w", err)
312	}
313	defer rsp.Body.Close()
314	if rsp.StatusCode != http.StatusOK {
315		return fmt.Errorf("failed to update agent: status code %d", rsp.StatusCode)
316	}
317	return nil
318}
319
320// SendMessage sends a message to the agent for a workspace.
321func (c *Client) SendMessage(ctx context.Context, id string, sessionID, prompt string, attachments ...message.Attachment) error {
322	protoAttachments := make([]proto.Attachment, len(attachments))
323	for i, a := range attachments {
324		protoAttachments[i] = proto.Attachment{
325			FilePath: a.FilePath,
326			FileName: a.FileName,
327			MimeType: a.MimeType,
328			Content:  a.Content,
329		}
330	}
331	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent", id), nil, jsonBody(proto.AgentMessage{
332		SessionID:   sessionID,
333		Prompt:      prompt,
334		Attachments: protoAttachments,
335	}), http.Header{"Content-Type": []string{"application/json"}})
336	if err != nil {
337		return fmt.Errorf("failed to send message to agent: %w", err)
338	}
339	defer rsp.Body.Close()
340	if rsp.StatusCode != http.StatusOK {
341		return fmt.Errorf("failed to send message to agent: status code %d", rsp.StatusCode)
342	}
343	return nil
344}
345
346// GetAgentSessionInfo retrieves the agent session info for a workspace.
347func (c *Client) GetAgentSessionInfo(ctx context.Context, id string, sessionID string) (*proto.AgentSession, error) {
348	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s", id, sessionID), nil, nil)
349	if err != nil {
350		return nil, fmt.Errorf("failed to get session agent info: %w", err)
351	}
352	defer rsp.Body.Close()
353	if rsp.StatusCode != http.StatusOK {
354		return nil, fmt.Errorf("failed to get session agent info: status code %d", rsp.StatusCode)
355	}
356	var info proto.AgentSession
357	if err := json.NewDecoder(rsp.Body).Decode(&info); err != nil {
358		return nil, fmt.Errorf("failed to decode session agent info: %w", err)
359	}
360	return &info, nil
361}
362
363// AgentSummarizeSession requests a session summarization.
364func (c *Client) AgentSummarizeSession(ctx context.Context, id string, sessionID string) error {
365	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/summarize", id, sessionID), nil, nil, nil)
366	if err != nil {
367		return fmt.Errorf("failed to summarize session: %w", err)
368	}
369	defer rsp.Body.Close()
370	if rsp.StatusCode != http.StatusOK {
371		return fmt.Errorf("failed to summarize session: status code %d", rsp.StatusCode)
372	}
373	return nil
374}
375
376// InitiateAgentProcessing triggers agent initialization on the server.
377func (c *Client) InitiateAgentProcessing(ctx context.Context, id string) error {
378	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/init", id), nil, nil, nil)
379	if err != nil {
380		return fmt.Errorf("failed to initiate session agent processing: %w", err)
381	}
382	defer rsp.Body.Close()
383	if rsp.StatusCode != http.StatusOK {
384		return fmt.Errorf("failed to initiate session agent processing: status code %d", rsp.StatusCode)
385	}
386	return nil
387}
388
389// ListMessages retrieves all messages for a session.
390func (c *Client) ListMessages(ctx context.Context, id string, sessionID string) ([]message.Message, error) {
391	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/messages", id, sessionID), nil, nil)
392	if err != nil {
393		return nil, fmt.Errorf("failed to get messages: %w", err)
394	}
395	defer rsp.Body.Close()
396	if rsp.StatusCode != http.StatusOK {
397		return nil, fmt.Errorf("failed to get messages: status code %d", rsp.StatusCode)
398	}
399	var protoMsgs []proto.Message
400	if err := json.NewDecoder(rsp.Body).Decode(&protoMsgs); err != nil && !errors.Is(err, io.EOF) {
401		return nil, fmt.Errorf("failed to decode messages: %w", err)
402	}
403	return protoToMessages(protoMsgs), nil
404}
405
406// GetSession retrieves a specific session.
407func (c *Client) GetSession(ctx context.Context, id string, sessionID string) (*session.Session, error) {
408	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sessionID), nil, nil)
409	if err != nil {
410		return nil, fmt.Errorf("failed to get session: %w", err)
411	}
412	defer rsp.Body.Close()
413	if rsp.StatusCode != http.StatusOK {
414		return nil, fmt.Errorf("failed to get session: status code %d", rsp.StatusCode)
415	}
416	var sess session.Session
417	if err := json.NewDecoder(rsp.Body).Decode(&sess); err != nil {
418		return nil, fmt.Errorf("failed to decode session: %w", err)
419	}
420	return &sess, nil
421}
422
423// ListSessionHistoryFiles retrieves history files for a session.
424func (c *Client) ListSessionHistoryFiles(ctx context.Context, id string, sessionID string) ([]history.File, error) {
425	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/history", id, sessionID), nil, nil)
426	if err != nil {
427		return nil, fmt.Errorf("failed to get session history files: %w", err)
428	}
429	defer rsp.Body.Close()
430	if rsp.StatusCode != http.StatusOK {
431		return nil, fmt.Errorf("failed to get session history files: status code %d", rsp.StatusCode)
432	}
433	var files []history.File
434	if err := json.NewDecoder(rsp.Body).Decode(&files); err != nil {
435		return nil, fmt.Errorf("failed to decode session history files: %w", err)
436	}
437	return files, nil
438}
439
440// CreateSession creates a new session in a workspace.
441func (c *Client) CreateSession(ctx context.Context, id string, title string) (*session.Session, error) {
442	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/sessions", id), nil, jsonBody(session.Session{Title: title}), http.Header{"Content-Type": []string{"application/json"}})
443	if err != nil {
444		return nil, fmt.Errorf("failed to create session: %w", err)
445	}
446	defer rsp.Body.Close()
447	if rsp.StatusCode != http.StatusOK {
448		return nil, fmt.Errorf("failed to create session: status code %d", rsp.StatusCode)
449	}
450	var sess session.Session
451	if err := json.NewDecoder(rsp.Body).Decode(&sess); err != nil {
452		return nil, fmt.Errorf("failed to decode session: %w", err)
453	}
454	return &sess, nil
455}
456
457// ListSessions lists all sessions in a workspace.
458func (c *Client) ListSessions(ctx context.Context, id string) ([]session.Session, error) {
459	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions", id), nil, nil)
460	if err != nil {
461		return nil, fmt.Errorf("failed to get sessions: %w", err)
462	}
463	defer rsp.Body.Close()
464	if rsp.StatusCode != http.StatusOK {
465		return nil, fmt.Errorf("failed to get sessions: status code %d", rsp.StatusCode)
466	}
467	var sessions []session.Session
468	if err := json.NewDecoder(rsp.Body).Decode(&sessions); err != nil {
469		return nil, fmt.Errorf("failed to decode sessions: %w", err)
470	}
471	return sessions, nil
472}
473
474// GrantPermission grants a permission on a workspace.
475func (c *Client) GrantPermission(ctx context.Context, id string, req proto.PermissionGrant) error {
476	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/permissions/grant", id), nil, jsonBody(req), http.Header{"Content-Type": []string{"application/json"}})
477	if err != nil {
478		return fmt.Errorf("failed to grant permission: %w", err)
479	}
480	defer rsp.Body.Close()
481	if rsp.StatusCode != http.StatusOK {
482		return fmt.Errorf("failed to grant permission: status code %d", rsp.StatusCode)
483	}
484	return nil
485}
486
487// SetPermissionsSkipRequests sets the skip-requests flag for a workspace.
488func (c *Client) SetPermissionsSkipRequests(ctx context.Context, id string, skip bool) error {
489	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"}})
490	if err != nil {
491		return fmt.Errorf("failed to set permissions skip requests: %w", err)
492	}
493	defer rsp.Body.Close()
494	if rsp.StatusCode != http.StatusOK {
495		return fmt.Errorf("failed to set permissions skip requests: status code %d", rsp.StatusCode)
496	}
497	return nil
498}
499
500// GetPermissionsSkipRequests retrieves the skip-requests flag for a workspace.
501func (c *Client) GetPermissionsSkipRequests(ctx context.Context, id string) (bool, error) {
502	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/permissions/skip", id), nil, nil)
503	if err != nil {
504		return false, fmt.Errorf("failed to get permissions skip requests: %w", err)
505	}
506	defer rsp.Body.Close()
507	if rsp.StatusCode != http.StatusOK {
508		return false, fmt.Errorf("failed to get permissions skip requests: status code %d", rsp.StatusCode)
509	}
510	var skip proto.PermissionSkipRequest
511	if err := json.NewDecoder(rsp.Body).Decode(&skip); err != nil {
512		return false, fmt.Errorf("failed to decode permissions skip requests: %w", err)
513	}
514	return skip.Skip, nil
515}
516
517// GetConfig retrieves the workspace-specific configuration.
518func (c *Client) GetConfig(ctx context.Context, id string) (*config.Config, error) {
519	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/config", id), nil, nil)
520	if err != nil {
521		return nil, fmt.Errorf("failed to get config: %w", err)
522	}
523	defer rsp.Body.Close()
524	if rsp.StatusCode != http.StatusOK {
525		return nil, fmt.Errorf("failed to get config: status code %d", rsp.StatusCode)
526	}
527	var cfg config.Config
528	if err := json.NewDecoder(rsp.Body).Decode(&cfg); err != nil {
529		return nil, fmt.Errorf("failed to decode config: %w", err)
530	}
531	return &cfg, nil
532}
533
534func jsonBody(v any) *bytes.Buffer {
535	b := new(bytes.Buffer)
536	m, _ := json.Marshal(v)
537	b.Write(m)
538	return b
539}
540
541// SaveSession updates a session in a workspace.
542func (c *Client) SaveSession(ctx context.Context, id string, sess session.Session) (*session.Session, error) {
543	rsp, err := c.put(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sess.ID), nil, jsonBody(sess), http.Header{"Content-Type": []string{"application/json"}})
544	if err != nil {
545		return nil, fmt.Errorf("failed to save session: %w", err)
546	}
547	defer rsp.Body.Close()
548	if rsp.StatusCode != http.StatusOK {
549		return nil, fmt.Errorf("failed to save session: status code %d", rsp.StatusCode)
550	}
551	var saved session.Session
552	if err := json.NewDecoder(rsp.Body).Decode(&saved); err != nil {
553		return nil, fmt.Errorf("failed to decode session: %w", err)
554	}
555	return &saved, nil
556}
557
558// DeleteSession deletes a session from a workspace.
559func (c *Client) DeleteSession(ctx context.Context, id string, sessionID string) error {
560	rsp, err := c.delete(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sessionID), nil, nil)
561	if err != nil {
562		return fmt.Errorf("failed to delete session: %w", err)
563	}
564	defer rsp.Body.Close()
565	if rsp.StatusCode != http.StatusOK {
566		return fmt.Errorf("failed to delete session: status code %d", rsp.StatusCode)
567	}
568	return nil
569}
570
571// ListUserMessages retrieves user-role messages for a session.
572func (c *Client) ListUserMessages(ctx context.Context, id string, sessionID string) ([]message.Message, error) {
573	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/messages/user", id, sessionID), nil, nil)
574	if err != nil {
575		return nil, fmt.Errorf("failed to get user messages: %w", err)
576	}
577	defer rsp.Body.Close()
578	if rsp.StatusCode != http.StatusOK {
579		return nil, fmt.Errorf("failed to get user messages: status code %d", rsp.StatusCode)
580	}
581	var protoMsgs []proto.Message
582	if err := json.NewDecoder(rsp.Body).Decode(&protoMsgs); err != nil && !errors.Is(err, io.EOF) {
583		return nil, fmt.Errorf("failed to decode user messages: %w", err)
584	}
585	return protoToMessages(protoMsgs), nil
586}
587
588// ListAllUserMessages retrieves all user-role messages across sessions.
589func (c *Client) ListAllUserMessages(ctx context.Context, id string) ([]message.Message, error) {
590	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/messages/user", id), nil, nil)
591	if err != nil {
592		return nil, fmt.Errorf("failed to get all user messages: %w", err)
593	}
594	defer rsp.Body.Close()
595	if rsp.StatusCode != http.StatusOK {
596		return nil, fmt.Errorf("failed to get all user messages: status code %d", rsp.StatusCode)
597	}
598	var protoMsgs []proto.Message
599	if err := json.NewDecoder(rsp.Body).Decode(&protoMsgs); err != nil && !errors.Is(err, io.EOF) {
600		return nil, fmt.Errorf("failed to decode all user messages: %w", err)
601	}
602	return protoToMessages(protoMsgs), nil
603}
604
605// CancelAgentSession cancels an ongoing agent operation for a session.
606func (c *Client) CancelAgentSession(ctx context.Context, id string, sessionID string) error {
607	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/cancel", id, sessionID), nil, nil, nil)
608	if err != nil {
609		return fmt.Errorf("failed to cancel agent session: %w", err)
610	}
611	defer rsp.Body.Close()
612	if rsp.StatusCode != http.StatusOK {
613		return fmt.Errorf("failed to cancel agent session: status code %d", rsp.StatusCode)
614	}
615	return nil
616}
617
618// GetAgentSessionQueuedPromptsList retrieves the list of queued prompt
619// strings for a session.
620func (c *Client) GetAgentSessionQueuedPromptsList(ctx context.Context, id string, sessionID string) ([]string, error) {
621	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/list", id, sessionID), nil, nil)
622	if err != nil {
623		return nil, fmt.Errorf("failed to get queued prompts list: %w", err)
624	}
625	defer rsp.Body.Close()
626	if rsp.StatusCode != http.StatusOK {
627		return nil, fmt.Errorf("failed to get queued prompts list: status code %d", rsp.StatusCode)
628	}
629	var prompts []string
630	if err := json.NewDecoder(rsp.Body).Decode(&prompts); err != nil {
631		return nil, fmt.Errorf("failed to decode queued prompts list: %w", err)
632	}
633	return prompts, nil
634}
635
636// GetDefaultSmallModel retrieves the default small model for a provider.
637func (c *Client) GetDefaultSmallModel(ctx context.Context, id string, providerID string) (*config.SelectedModel, error) {
638	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/default-small-model", id), url.Values{"provider_id": []string{providerID}}, nil)
639	if err != nil {
640		return nil, fmt.Errorf("failed to get default small model: %w", err)
641	}
642	defer rsp.Body.Close()
643	if rsp.StatusCode != http.StatusOK {
644		return nil, fmt.Errorf("failed to get default small model: status code %d", rsp.StatusCode)
645	}
646	var model config.SelectedModel
647	if err := json.NewDecoder(rsp.Body).Decode(&model); err != nil {
648		return nil, fmt.Errorf("failed to decode default small model: %w", err)
649	}
650	return &model, nil
651}
652
653// FileTrackerRecordRead records a file read for a session.
654func (c *Client) FileTrackerRecordRead(ctx context.Context, id string, sessionID, path string) error {
655	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/filetracker/read", id), nil, jsonBody(struct {
656		SessionID string `json:"session_id"`
657		Path      string `json:"path"`
658	}{SessionID: sessionID, Path: path}), http.Header{"Content-Type": []string{"application/json"}})
659	if err != nil {
660		return fmt.Errorf("failed to record file read: %w", err)
661	}
662	defer rsp.Body.Close()
663	if rsp.StatusCode != http.StatusOK {
664		return fmt.Errorf("failed to record file read: status code %d", rsp.StatusCode)
665	}
666	return nil
667}
668
669// FileTrackerLastReadTime returns the last read time for a file in a
670// session.
671func (c *Client) FileTrackerLastReadTime(ctx context.Context, id string, sessionID, path string) (time.Time, error) {
672	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/filetracker/lastread", id), url.Values{
673		"session_id": []string{sessionID},
674		"path":       []string{path},
675	}, nil)
676	if err != nil {
677		return time.Time{}, fmt.Errorf("failed to get last read time: %w", err)
678	}
679	defer rsp.Body.Close()
680	if rsp.StatusCode != http.StatusOK {
681		return time.Time{}, fmt.Errorf("failed to get last read time: status code %d", rsp.StatusCode)
682	}
683	var t time.Time
684	if err := json.NewDecoder(rsp.Body).Decode(&t); err != nil {
685		return time.Time{}, fmt.Errorf("failed to decode last read time: %w", err)
686	}
687	return t, nil
688}
689
690// FileTrackerListReadFiles returns the list of read files for a session.
691func (c *Client) FileTrackerListReadFiles(ctx context.Context, id string, sessionID string) ([]string, error) {
692	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/filetracker/files", id, sessionID), nil, nil)
693	if err != nil {
694		return nil, fmt.Errorf("failed to get read files: %w", err)
695	}
696	defer rsp.Body.Close()
697	if rsp.StatusCode != http.StatusOK {
698		return nil, fmt.Errorf("failed to get read files: status code %d", rsp.StatusCode)
699	}
700	var files []string
701	if err := json.NewDecoder(rsp.Body).Decode(&files); err != nil {
702		return nil, fmt.Errorf("failed to decode read files: %w", err)
703	}
704	return files, nil
705}
706
707// LSPStart starts an LSP server for a path.
708func (c *Client) LSPStart(ctx context.Context, id string, path string) error {
709	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/lsps/start", id), nil, jsonBody(struct {
710		Path string `json:"path"`
711	}{Path: path}), http.Header{"Content-Type": []string{"application/json"}})
712	if err != nil {
713		return fmt.Errorf("failed to start LSP: %w", err)
714	}
715	defer rsp.Body.Close()
716	if rsp.StatusCode != http.StatusOK {
717		return fmt.Errorf("failed to start LSP: status code %d", rsp.StatusCode)
718	}
719	return nil
720}
721
722// LSPStopAll stops all LSP servers for a workspace.
723func (c *Client) LSPStopAll(ctx context.Context, id string) error {
724	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/lsps/stop", id), nil, nil, nil)
725	if err != nil {
726		return fmt.Errorf("failed to stop LSPs: %w", err)
727	}
728	defer rsp.Body.Close()
729	if rsp.StatusCode != http.StatusOK {
730		return fmt.Errorf("failed to stop LSPs: status code %d", rsp.StatusCode)
731	}
732	return nil
733}
734
735func protoToMessages(msgs []proto.Message) []message.Message {
736	out := make([]message.Message, len(msgs))
737	for i, m := range msgs {
738		out[i] = protoToMessage(m)
739	}
740	return out
741}
742
743func protoToMessage(m proto.Message) message.Message {
744	msg := message.Message{
745		ID:        m.ID,
746		SessionID: m.SessionID,
747		Role:      message.MessageRole(m.Role),
748		Model:     m.Model,
749		Provider:  m.Provider,
750		CreatedAt: m.CreatedAt,
751		UpdatedAt: m.UpdatedAt,
752	}
753
754	for _, p := range m.Parts {
755		switch v := p.(type) {
756		case proto.TextContent:
757			msg.Parts = append(msg.Parts, message.TextContent{Text: v.Text})
758		case proto.ReasoningContent:
759			msg.Parts = append(msg.Parts, message.ReasoningContent{
760				Thinking:   v.Thinking,
761				Signature:  v.Signature,
762				StartedAt:  v.StartedAt,
763				FinishedAt: v.FinishedAt,
764			})
765		case proto.ToolCall:
766			msg.Parts = append(msg.Parts, message.ToolCall{
767				ID:       v.ID,
768				Name:     v.Name,
769				Input:    v.Input,
770				Finished: v.Finished,
771			})
772		case proto.ToolResult:
773			msg.Parts = append(msg.Parts, message.ToolResult{
774				ToolCallID: v.ToolCallID,
775				Name:       v.Name,
776				Content:    v.Content,
777				IsError:    v.IsError,
778			})
779		case proto.Finish:
780			msg.Parts = append(msg.Parts, message.Finish{
781				Reason:  message.FinishReason(v.Reason),
782				Time:    v.Time,
783				Message: v.Message,
784				Details: v.Details,
785			})
786		case proto.ImageURLContent:
787			msg.Parts = append(msg.Parts, message.ImageURLContent{URL: v.URL, Detail: v.Detail})
788		case proto.BinaryContent:
789			msg.Parts = append(msg.Parts, message.BinaryContent{Path: v.Path, MIMEType: v.MIMEType, Data: v.Data})
790		}
791	}
792
793	return msg
794}