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 && rsp.StatusCode != http.StatusAccepted {
427		if msg := decodeErrorMessage(rsp.Body); msg != "" {
428			return fmt.Errorf("failed to send message to agent: status code %d: %s", rsp.StatusCode, msg)
429		}
430		return fmt.Errorf("failed to send message to agent: status code %d", rsp.StatusCode)
431	}
432	return nil
433}
434
435// decodeErrorMessage attempts to decode the response body as a
436// proto.Error and returns its message. It returns an empty string
437// when the body is empty or cannot be decoded into a proto.Error
438// with a non-empty message, letting callers fall back to a
439// status-only error.
440func decodeErrorMessage(body io.Reader) string {
441	var e proto.Error
442	if err := json.NewDecoder(body).Decode(&e); err != nil {
443		return ""
444	}
445	return e.Message
446}
447
448// GetAgentSessionInfo retrieves the agent session info for a workspace.
449func (c *Client) GetAgentSessionInfo(ctx context.Context, id string, sessionID string) (*proto.AgentSession, error) {
450	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s", id, sessionID), nil, nil)
451	if err != nil {
452		return nil, fmt.Errorf("failed to get session agent info: %w", err)
453	}
454	defer rsp.Body.Close()
455	if rsp.StatusCode != http.StatusOK {
456		return nil, fmt.Errorf("failed to get session agent info: status code %d", rsp.StatusCode)
457	}
458	var info proto.AgentSession
459	if err := json.NewDecoder(rsp.Body).Decode(&info); err != nil {
460		return nil, fmt.Errorf("failed to decode session agent info: %w", err)
461	}
462	return &info, nil
463}
464
465// AgentSummarizeSession requests a session summarization.
466func (c *Client) AgentSummarizeSession(ctx context.Context, id string, sessionID string) error {
467	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/summarize", id, sessionID), nil, nil, nil)
468	if err != nil {
469		return fmt.Errorf("failed to summarize session: %w", err)
470	}
471	defer rsp.Body.Close()
472	if rsp.StatusCode != http.StatusOK {
473		return fmt.Errorf("failed to summarize session: status code %d", rsp.StatusCode)
474	}
475	return nil
476}
477
478// InitiateAgentProcessing triggers agent initialization on the server.
479func (c *Client) InitiateAgentProcessing(ctx context.Context, id string) error {
480	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/init", id), nil, nil, nil)
481	if err != nil {
482		return fmt.Errorf("failed to initiate session agent processing: %w", err)
483	}
484	defer rsp.Body.Close()
485	if rsp.StatusCode != http.StatusOK {
486		return fmt.Errorf("failed to initiate session agent processing: status code %d", rsp.StatusCode)
487	}
488	return nil
489}
490
491// ListMessages retrieves all messages for a session as proto types.
492func (c *Client) ListMessages(ctx context.Context, id string, sessionID string) ([]proto.Message, error) {
493	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/messages", id, sessionID), nil, nil)
494	if err != nil {
495		return nil, fmt.Errorf("failed to get messages: %w", err)
496	}
497	defer rsp.Body.Close()
498	if rsp.StatusCode != http.StatusOK {
499		return nil, fmt.Errorf("failed to get messages: status code %d", rsp.StatusCode)
500	}
501	var msgs []proto.Message
502	if err := json.NewDecoder(rsp.Body).Decode(&msgs); err != nil && !errors.Is(err, io.EOF) {
503		return nil, fmt.Errorf("failed to decode messages: %w", err)
504	}
505	return msgs, nil
506}
507
508// GetSession retrieves a specific session as a proto type.
509func (c *Client) GetSession(ctx context.Context, id string, sessionID string) (*proto.Session, error) {
510	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sessionID), nil, nil)
511	if err != nil {
512		return nil, fmt.Errorf("failed to get session: %w", err)
513	}
514	defer rsp.Body.Close()
515	if rsp.StatusCode != http.StatusOK {
516		return nil, fmt.Errorf("failed to get session: status code %d", rsp.StatusCode)
517	}
518	var sess proto.Session
519	if err := json.NewDecoder(rsp.Body).Decode(&sess); err != nil {
520		return nil, fmt.Errorf("failed to decode session: %w", err)
521	}
522	return &sess, nil
523}
524
525// ListSessionHistoryFiles retrieves history files for a session as proto types.
526func (c *Client) ListSessionHistoryFiles(ctx context.Context, id string, sessionID string) ([]proto.File, error) {
527	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/history", id, sessionID), nil, nil)
528	if err != nil {
529		return nil, fmt.Errorf("failed to get session history files: %w", err)
530	}
531	defer rsp.Body.Close()
532	if rsp.StatusCode != http.StatusOK {
533		return nil, fmt.Errorf("failed to get session history files: status code %d", rsp.StatusCode)
534	}
535	var files []proto.File
536	if err := json.NewDecoder(rsp.Body).Decode(&files); err != nil {
537		return nil, fmt.Errorf("failed to decode session history files: %w", err)
538	}
539	return files, nil
540}
541
542// CreateSession creates a new session in a workspace as a proto type.
543func (c *Client) CreateSession(ctx context.Context, id string, title string) (*proto.Session, error) {
544	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/sessions", id), nil, jsonBody(proto.Session{Title: title}), http.Header{"Content-Type": []string{"application/json"}})
545	if err != nil {
546		return nil, fmt.Errorf("failed to create session: %w", err)
547	}
548	defer rsp.Body.Close()
549	if rsp.StatusCode != http.StatusOK {
550		return nil, fmt.Errorf("failed to create session: status code %d", rsp.StatusCode)
551	}
552	var sess proto.Session
553	if err := json.NewDecoder(rsp.Body).Decode(&sess); err != nil {
554		return nil, fmt.Errorf("failed to decode session: %w", err)
555	}
556	return &sess, nil
557}
558
559// ListSessions lists all sessions in a workspace as proto types.
560func (c *Client) ListSessions(ctx context.Context, id string) ([]proto.Session, error) {
561	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions", id), nil, nil)
562	if err != nil {
563		return nil, fmt.Errorf("failed to get sessions: %w", err)
564	}
565	defer rsp.Body.Close()
566	if rsp.StatusCode != http.StatusOK {
567		return nil, fmt.Errorf("failed to get sessions: status code %d", rsp.StatusCode)
568	}
569	var sessions []proto.Session
570	if err := json.NewDecoder(rsp.Body).Decode(&sessions); err != nil {
571		return nil, fmt.Errorf("failed to decode sessions: %w", err)
572	}
573	return sessions, nil
574}
575
576// GrantPermission grants a permission on a workspace. The returned
577// bool reports whether this call resolved the pending request (true)
578// or found it already resolved by a previous caller (false). A false
579// value is not an error — it just means another subscriber resolved
580// the same request first.
581func (c *Client) GrantPermission(ctx context.Context, id string, req proto.PermissionGrant) (bool, error) {
582	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/permissions/grant", id), nil, jsonBody(req), http.Header{"Content-Type": []string{"application/json"}})
583	if err != nil {
584		return false, fmt.Errorf("failed to grant permission: %w", err)
585	}
586	defer rsp.Body.Close()
587	if rsp.StatusCode != http.StatusOK {
588		return false, fmt.Errorf("failed to grant permission: status code %d", rsp.StatusCode)
589	}
590	var resp proto.PermissionGrantResponse
591	if err := json.NewDecoder(rsp.Body).Decode(&resp); err != nil {
592		return false, fmt.Errorf("failed to decode grant permission response: %w", err)
593	}
594	return resp.Resolved, nil
595}
596
597// SetPermissionsSkipRequests sets the skip-requests flag for a workspace.
598func (c *Client) SetPermissionsSkipRequests(ctx context.Context, id string, skip bool) error {
599	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"}})
600	if err != nil {
601		return fmt.Errorf("failed to set permissions skip requests: %w", err)
602	}
603	defer rsp.Body.Close()
604	if rsp.StatusCode != http.StatusOK {
605		return fmt.Errorf("failed to set permissions skip requests: status code %d", rsp.StatusCode)
606	}
607	return nil
608}
609
610// GetPermissionsSkipRequests retrieves the skip-requests flag for a workspace.
611func (c *Client) GetPermissionsSkipRequests(ctx context.Context, id string) (bool, error) {
612	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/permissions/skip", id), nil, nil)
613	if err != nil {
614		return false, fmt.Errorf("failed to get permissions skip requests: %w", err)
615	}
616	defer rsp.Body.Close()
617	if rsp.StatusCode != http.StatusOK {
618		return false, fmt.Errorf("failed to get permissions skip requests: status code %d", rsp.StatusCode)
619	}
620	var skip proto.PermissionSkipRequest
621	if err := json.NewDecoder(rsp.Body).Decode(&skip); err != nil {
622		return false, fmt.Errorf("failed to decode permissions skip requests: %w", err)
623	}
624	return skip.Skip, nil
625}
626
627// GetConfig retrieves the workspace-specific configuration.
628func (c *Client) GetConfig(ctx context.Context, id string) (*config.Config, error) {
629	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/config", id), nil, nil)
630	if err != nil {
631		return nil, fmt.Errorf("failed to get config: %w", err)
632	}
633	defer rsp.Body.Close()
634	if rsp.StatusCode != http.StatusOK {
635		return nil, fmt.Errorf("failed to get config: status code %d", rsp.StatusCode)
636	}
637	var cfg config.Config
638	if err := json.NewDecoder(rsp.Body).Decode(&cfg); err != nil {
639		return nil, fmt.Errorf("failed to decode config: %w", err)
640	}
641	return &cfg, nil
642}
643
644func jsonBody(v any) *bytes.Buffer {
645	b := new(bytes.Buffer)
646	m, _ := json.Marshal(v)
647	b.Write(m)
648	return b
649}
650
651// SaveSession updates a session in a workspace, returning a proto type.
652func (c *Client) SaveSession(ctx context.Context, id string, sess proto.Session) (*proto.Session, error) {
653	rsp, err := c.put(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sess.ID), nil, jsonBody(sess), http.Header{"Content-Type": []string{"application/json"}})
654	if err != nil {
655		return nil, fmt.Errorf("failed to save session: %w", err)
656	}
657	defer rsp.Body.Close()
658	if rsp.StatusCode != http.StatusOK {
659		return nil, fmt.Errorf("failed to save session: status code %d", rsp.StatusCode)
660	}
661	var saved proto.Session
662	if err := json.NewDecoder(rsp.Body).Decode(&saved); err != nil {
663		return nil, fmt.Errorf("failed to decode session: %w", err)
664	}
665	return &saved, nil
666}
667
668// DeleteSession deletes a session from a workspace.
669func (c *Client) DeleteSession(ctx context.Context, id string, sessionID string) error {
670	rsp, err := c.delete(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s", id, sessionID), nil, nil)
671	if err != nil {
672		return fmt.Errorf("failed to delete session: %w", err)
673	}
674	defer rsp.Body.Close()
675	if rsp.StatusCode != http.StatusOK {
676		return fmt.Errorf("failed to delete session: status code %d", rsp.StatusCode)
677	}
678	return nil
679}
680
681// ListUserMessages retrieves user-role messages for a session as proto types.
682func (c *Client) ListUserMessages(ctx context.Context, id string, sessionID string) ([]proto.Message, error) {
683	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/messages/user", id, sessionID), nil, nil)
684	if err != nil {
685		return nil, fmt.Errorf("failed to get user messages: %w", err)
686	}
687	defer rsp.Body.Close()
688	if rsp.StatusCode != http.StatusOK {
689		return nil, fmt.Errorf("failed to get user messages: status code %d", rsp.StatusCode)
690	}
691	var msgs []proto.Message
692	if err := json.NewDecoder(rsp.Body).Decode(&msgs); err != nil && !errors.Is(err, io.EOF) {
693		return nil, fmt.Errorf("failed to decode user messages: %w", err)
694	}
695	return msgs, nil
696}
697
698// ListAllUserMessages retrieves all user-role messages across sessions as proto types.
699func (c *Client) ListAllUserMessages(ctx context.Context, id string) ([]proto.Message, error) {
700	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/messages/user", id), nil, nil)
701	if err != nil {
702		return nil, fmt.Errorf("failed to get all user messages: %w", err)
703	}
704	defer rsp.Body.Close()
705	if rsp.StatusCode != http.StatusOK {
706		return nil, fmt.Errorf("failed to get all user messages: status code %d", rsp.StatusCode)
707	}
708	var msgs []proto.Message
709	if err := json.NewDecoder(rsp.Body).Decode(&msgs); err != nil && !errors.Is(err, io.EOF) {
710		return nil, fmt.Errorf("failed to decode all user messages: %w", err)
711	}
712	return msgs, nil
713}
714
715// CancelAgentSession cancels an ongoing agent operation for a session.
716func (c *Client) CancelAgentSession(ctx context.Context, id string, sessionID string) error {
717	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/cancel", id, sessionID), nil, nil, nil)
718	if err != nil {
719		return fmt.Errorf("failed to cancel agent session: %w", err)
720	}
721	defer rsp.Body.Close()
722	if rsp.StatusCode != http.StatusOK {
723		return fmt.Errorf("failed to cancel agent session: status code %d", rsp.StatusCode)
724	}
725	return nil
726}
727
728// GetAgentSessionQueuedPromptsList retrieves the list of queued prompt
729// strings for a session.
730func (c *Client) GetAgentSessionQueuedPromptsList(ctx context.Context, id string, sessionID string) ([]string, error) {
731	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/sessions/%s/prompts/list", id, sessionID), nil, nil)
732	if err != nil {
733		return nil, fmt.Errorf("failed to get queued prompts list: %w", err)
734	}
735	defer rsp.Body.Close()
736	if rsp.StatusCode != http.StatusOK {
737		return nil, fmt.Errorf("failed to get queued prompts list: status code %d", rsp.StatusCode)
738	}
739	var prompts []string
740	if err := json.NewDecoder(rsp.Body).Decode(&prompts); err != nil {
741		return nil, fmt.Errorf("failed to decode queued prompts list: %w", err)
742	}
743	return prompts, nil
744}
745
746// GetDefaultSmallModel retrieves the default small model for a provider.
747func (c *Client) GetDefaultSmallModel(ctx context.Context, id string, providerID string) (*config.SelectedModel, error) {
748	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/agent/default-small-model", id), url.Values{"provider_id": []string{providerID}}, nil)
749	if err != nil {
750		return nil, fmt.Errorf("failed to get default small model: %w", err)
751	}
752	defer rsp.Body.Close()
753	if rsp.StatusCode != http.StatusOK {
754		return nil, fmt.Errorf("failed to get default small model: status code %d", rsp.StatusCode)
755	}
756	var model config.SelectedModel
757	if err := json.NewDecoder(rsp.Body).Decode(&model); err != nil {
758		return nil, fmt.Errorf("failed to decode default small model: %w", err)
759	}
760	return &model, nil
761}
762
763// FileTrackerRecordRead records a file read for a session.
764func (c *Client) FileTrackerRecordRead(ctx context.Context, id string, sessionID, path string) error {
765	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/filetracker/read", id), nil, jsonBody(struct {
766		SessionID string `json:"session_id"`
767		Path      string `json:"path"`
768	}{SessionID: sessionID, Path: path}), http.Header{"Content-Type": []string{"application/json"}})
769	if err != nil {
770		return fmt.Errorf("failed to record file read: %w", err)
771	}
772	defer rsp.Body.Close()
773	if rsp.StatusCode != http.StatusOK {
774		return fmt.Errorf("failed to record file read: status code %d", rsp.StatusCode)
775	}
776	return nil
777}
778
779// FileTrackerLastReadTime returns the last read time for a file in a
780// session.
781func (c *Client) FileTrackerLastReadTime(ctx context.Context, id string, sessionID, path string) (time.Time, error) {
782	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/filetracker/lastread", id), url.Values{
783		"session_id": []string{sessionID},
784		"path":       []string{path},
785	}, nil)
786	if err != nil {
787		return time.Time{}, fmt.Errorf("failed to get last read time: %w", err)
788	}
789	defer rsp.Body.Close()
790	if rsp.StatusCode != http.StatusOK {
791		return time.Time{}, fmt.Errorf("failed to get last read time: status code %d", rsp.StatusCode)
792	}
793	var t time.Time
794	if err := json.NewDecoder(rsp.Body).Decode(&t); err != nil {
795		return time.Time{}, fmt.Errorf("failed to decode last read time: %w", err)
796	}
797	return t, nil
798}
799
800// FileTrackerListReadFiles returns the list of read files for a session.
801func (c *Client) FileTrackerListReadFiles(ctx context.Context, id string, sessionID string) ([]string, error) {
802	rsp, err := c.get(ctx, fmt.Sprintf("/workspaces/%s/sessions/%s/filetracker/files", id, sessionID), nil, nil)
803	if err != nil {
804		return nil, fmt.Errorf("failed to get read files: %w", err)
805	}
806	defer rsp.Body.Close()
807	if rsp.StatusCode != http.StatusOK {
808		return nil, fmt.Errorf("failed to get read files: status code %d", rsp.StatusCode)
809	}
810	var files []string
811	if err := json.NewDecoder(rsp.Body).Decode(&files); err != nil {
812		return nil, fmt.Errorf("failed to decode read files: %w", err)
813	}
814	return files, nil
815}
816
817// LSPStart starts an LSP server for a path.
818func (c *Client) LSPStart(ctx context.Context, id string, path string) error {
819	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/lsps/start", id), nil, jsonBody(struct {
820		Path string `json:"path"`
821	}{Path: path}), http.Header{"Content-Type": []string{"application/json"}})
822	if err != nil {
823		return fmt.Errorf("failed to start LSP: %w", err)
824	}
825	defer rsp.Body.Close()
826	if rsp.StatusCode != http.StatusOK {
827		return fmt.Errorf("failed to start LSP: status code %d", rsp.StatusCode)
828	}
829	return nil
830}
831
832// LSPStopAll stops all LSP servers for a workspace.
833func (c *Client) LSPStopAll(ctx context.Context, id string) error {
834	rsp, err := c.post(ctx, fmt.Sprintf("/workspaces/%s/lsps/stop", id), nil, nil, nil)
835	if err != nil {
836		return fmt.Errorf("failed to stop LSPs: %w", err)
837	}
838	defer rsp.Body.Close()
839	if rsp.StatusCode != http.StatusOK {
840		return fmt.Errorf("failed to stop LSPs: status code %d", rsp.StatusCode)
841	}
842	return nil
843}