Skip to content

Feat/Refactor: Server Client architecture and API#2455

Merged
aymanbagabas merged 25 commits intomainfrom
server-client-2
Apr 2, 2026
Merged

Feat/Refactor: Server Client architecture and API#2455
aymanbagabas merged 25 commits intomainfrom
server-client-2

Conversation

@aymanbagabas
Copy link
Copy Markdown
Member

@aymanbagabas aymanbagabas commented Mar 22, 2026

This change is a big and important one. It refactors Crush into a server/client architecture that can be used to interact with the agent loop via API. The server can manage multiple workspace sessions. A workspace is where Crush finds a .crush directory and a database (the data directory).

Right now, you can enable this feature using the CRUSH_CLIENT_SERVER=1 flag. Having this flag on will make Crush spin a detached server process, if there isn't any, before displaying the TUI. The server is an REST HTTP server that runs on a Unix socket by default. It can bind to whatever network type. You can control that via the -H command line flag. The default is crush -H unix:///tmp/crush-$(id -u).sock. To use a TCP connection, you can do crush -H tcp://localhost:3456. There is an OpenAPI endpoint at /v1/docs that gives a high overview of the API and how it looks like.

To run the server independently without a client or TUI, you can use crush server. That too accepts a -H flag to bind the server to a specific network address.

Fixes: #976

@aymanbagabas aymanbagabas requested a review from a team as a code owner March 22, 2026 10:04
Copilot AI review requested due to automatic review settings April 1, 2026 17:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors Crush into a client/server architecture with a REST API (served over Unix socket / named pipe by default) and introduces a new Workspace abstraction so the TUI/CLI can operate against either an in-process workspace or a remote server-managed workspace.

Changes:

  • Add Swagger/OpenAPI generation (swag annotations + Taskfile task) and expose API docs under /v1/docs.
  • Introduce internal/workspace.Workspace and an in-process implementation (AppWorkspace) to unify frontend interactions across local and client/server modes.
  • Adjust UI/CLI/server/client plumbing (version info, SSE events, LSP state surfacing, build metadata).

Reviewed changes

Copilot reviewed 72 out of 75 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
Taskfile.yaml Adds swag task to generate OpenAPI specs from annotations.
main.go Adds Swagger annotations for API metadata/base path.
internal/workspace/workspace.go Introduces the frontend-facing Workspace interface and shared types.
internal/workspace/app_workspace.go Implements Workspace by delegating to in-process app.App.
internal/version/version.go Adds build metadata (Commit) alongside Version.
internal/ui/util/util.go Removes logging side-effect from ReportError.
internal/ui/model/ui.go Switches UI to use com.Workspace instead of com.App for multiple operations.
internal/ui/model/header.go Updates header LSP diagnostic display to consume Workspace state.
internal/cmd/run.go Updates non-interactive run behavior and client/server connection handling.
internal/client/client.go Adds/adjusts client calls, including VersionInfo.
internal/server/server.go Wires server routes and implements server lifecycle methods.
internal/server/events.go Wraps internal pubsub events into SSE payload envelopes using proto types.
internal/proto/agent.go Defines AgentEvent/AgentEventType used in SSE/API payloads.
internal/backend/agent.go Implements server-side agent operations for workspaces.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

return ErrAgentNotInitialized
}

_, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt)
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backend.SendMessage drops AgentMessage.Attachments when invoking AgentCoordinator.Run, so client/server mode cannot send file/image attachments even though the API accepts them. Pass msg.Attachments through to Run (or explicitly reject attachments with a clear error if not supported).

Suggested change
_, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt)
_, err = ws.AgentCoordinator.Run(ctx, msg.SessionID, msg.Prompt, msg.Attachments)

Copilot uses AI. Check for mistakes.
Comment on lines +184 to +214
// ListenAndServe starts the server and begins accepting connections.
func (s *Server) ListenAndServe() error {
if s.ln != nil {
return fmt.Errorf("server already started")
}
ln, err := listen(s.network, s.Addr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", s.Addr, err)
}
return s.Serve(ln)
}

func (s *Server) closeListener() {
if s.ln != nil {
s.ln.Close()
s.ln = nil
}
}

// Close force closes all listeners and connections.
func (s *Server) Close() error {
defer func() { s.closeListener() }()
return s.h.Close()
}

// Shutdown gracefully shuts down the server without interrupting active
// connections.
func (s *Server) Shutdown(ctx context.Context) error {
defer func() { s.closeListener() }()
return s.h.Shutdown(ctx)
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListenAndServe creates a local listener but never assigns it to s.ln. As a result, the "server already started" guard never triggers and Close()/Shutdown() won't actually close the active listener via closeListener(). Store the listener in s.ln before serving (and clear it after Serve returns).

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +106
// VersionInfo retrieves the server's version information.
func (c *Client) VersionInfo(ctx context.Context) (*proto.VersionInfo, error) {
var vi proto.VersionInfo
rsp, err := c.get(ctx, "version", nil, nil)
if err != nil {
return nil, err
}
defer rsp.Body.Close()
if err := json.NewDecoder(rsp.Body).Decode(&vi); err != nil {
return nil, err
}
return &vi, nil
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client.VersionInfo calls c.get(ctx, "version", ...) without a leading slash, unlike other endpoints (e.g., "/health"). This can produce incorrect request paths depending on how sendReq joins paths. Use a consistent absolute path (e.g., "/version").

Copilot uses AI. Check for mistakes.
Comment on lines 72 to 75
// Cancel on SIGINT or SIGTERM.
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
defer cancel()

Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

signal.NotifyContext includes os.Kill, but SIGKILL cannot be trapped/handled, so this entry is ineffective and can be misleading. Replace with syscall.SIGTERM (and keep os.Interrupt) to support graceful shutdown on typical termination signals.

Copilot uses AI. Check for mistakes.
Comment on lines 75 to 84
availDetailWidth := width - leftPadding - rightPadding - lipgloss.Width(b.String()) - minHeaderDiags - diagToDetailsSpacing
lspErrorCount := 0
for _, info := range h.com.Workspace.LSPGetStates() {
lspErrorCount += info.DiagnosticCount
}
details := renderHeaderDetails(
h.com,
session,
h.com.App.LSPManager.Clients(),
lspErrorCount,
detailsOpen,
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

header.go sums LSPClientInfo.DiagnosticCount into lspErrorCount, but DiagnosticCount is the total number of diagnostics (all severities), not errors. This will render non-error diagnostics with the error icon/count. Either rename the variable/UI label to reflect "diagnostics", or compute the error-only count via LSPGetDiagnosticCounts(...).Error.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +15
// AgentEventType represents the type of agent event.
type AgentEventType string

const (
AgentEventTypeError AgentEventType = "error"
AgentEventTypeResponse AgentEventType = "response"
AgentEventTypeSummarize AgentEventType = "summarize"
)
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proto.AgentEventType only defines "error"/"response"/"summarize", but SSE wrapping casts notify.Type values like "agent_finished" into AgentEventType. This makes the exported type constants incomplete/misleading and can break clients that validate against the declared enum. Either add constants for the notification event types you emit, or use a dedicated proto type for notify.Notification events (and/or document that AgentEventType is an open set).

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +93
case pubsub.Event[notify.Notification]:
return envelope(pubsub.PayloadTypeAgentEvent, pubsub.Event[proto.AgentEvent]{
Type: e.Type,
Payload: proto.AgentEvent{
SessionID: e.Payload.SessionID,
SessionTitle: e.Payload.SessionTitle,
Type: proto.AgentEventType(e.Payload.Type),
},
})
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSE wrapping of notify.Notification builds proto.AgentEvent without setting the required Message field (no omitempty), so JSON will include an empty/zero "message" object for these notifications. Consider using a dedicated proto payload for agent notifications, or make AgentEvent.Message optional (pointer or omitempty) so notification-only events don't emit misleading fields.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@andreynering andreynering left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good for an initial iteraction!

There are a couple bugs, but since it's opt-in and experimental, we can merge to avoid conflicts and continue working on it.

Screenshots Image Image

@aymanbagabas aymanbagabas merged commit 28f2087 into main Apr 2, 2026
20 of 21 checks passed
@aymanbagabas aymanbagabas deleted the server-client-2 branch April 2, 2026 19:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Crush HTTP interface

3 participants