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