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