Skip to content

Commit 441334f

Browse files
committed
feat: expand HTTP server REST API for remote client access
Add comprehensive REST API endpoints to the HTTP server, enabling iOS app and other remote clients to manage all codes functionality over HTTP. New packages: - internal/chatsession: Claude chat session lifecycle management with WebSocket support for real-time I/O streaming New HTTP endpoints: - GET/POST /sessions, GET/DELETE /sessions/{id} - /sessions/{id}/ws (WebSocket), /sessions/{id}/interrupt, /sessions/{id}/resume - GET /projects, GET /projects/{name} - GET /profiles, POST /profiles/switch - GET /stats/summary, /stats/projects, /stats/models, POST /stats/refresh - GET /workflows, GET/POST /workflows/{name} - GET/POST /teams, plus extended team/task/agent sub-routes - POST /dispatch/simple (simplified dispatch variant) Other improvements: - Auto-generate and persist auth token if none configured (no manual setup needed) - Change default listen port from :8080 to :3456 - Add callback URL support for dispatch tasks - Split handlers into per-domain files (handlers_project, _session, _stats, etc.)
1 parent 063347f commit 441334f

35 files changed

Lines changed: 6444 additions & 41 deletions

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ require (
66
github.com/charmbracelet/bubbles v1.0.0
77
github.com/charmbracelet/bubbletea v1.3.10
88
github.com/charmbracelet/lipgloss v1.1.0
9+
github.com/gorilla/websocket v1.5.3
910
github.com/modelcontextprotocol/go-sdk v1.3.0
1011
github.com/spf13/cobra v1.10.1
1112
golang.org/x/term v0.40.0
13+
gopkg.in/yaml.v3 v3.0.1
1214
)
1315

1416
require (
@@ -38,6 +40,5 @@ require (
3840
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
3941
golang.org/x/oauth2 v0.30.0 // indirect
4042
golang.org/x/sys v0.41.0 // indirect
41-
golang.org/x/text v0.3.8 // indirect
42-
gopkg.in/yaml.v3 v3.0.1 // indirect
43+
golang.org/x/text v0.34.0 // indirect
4344
)

go.sum

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
3535
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
3636
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
3737
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
38+
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
39+
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
3840
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3941
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
4042
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -78,10 +80,11 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
7880
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
7981
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
8082
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
81-
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
82-
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
83-
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
84-
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
83+
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
84+
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
85+
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
86+
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
87+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
8588
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
8689
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
8790
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/chatsession/claude.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package chatsession
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
"os/exec"
8+
"strings"
9+
)
10+
11+
// spawnClaude starts a Claude CLI subprocess in stream-json mode.
12+
// If resumeSessionID is non-empty, the session is resumed.
13+
// Returns stdin writer, stdout reader, the command, and any error.
14+
func spawnClaude(projectPath, model, resumeSessionID string) (io.WriteCloser, io.ReadCloser, *exec.Cmd, error) {
15+
args := []string{
16+
"--output-format", "stream-json",
17+
"--input-format", "stream-json",
18+
"--verbose",
19+
}
20+
21+
if model != "" {
22+
args = append(args, "--model", model)
23+
}
24+
25+
if resumeSessionID != "" {
26+
args = append(args, "--resume", resumeSessionID)
27+
}
28+
29+
cmd := exec.Command("claude", args...)
30+
cmd.Dir = projectPath
31+
32+
// Build clean environment, unsetting CLAUDE_CODE_ENTRYPOINT to avoid nested detection.
33+
env := os.Environ()
34+
filtered := make([]string, 0, len(env))
35+
for _, e := range env {
36+
if !strings.HasPrefix(e, "CLAUDE_CODE_ENTRYPOINT=") {
37+
filtered = append(filtered, e)
38+
}
39+
}
40+
cmd.Env = filtered
41+
42+
stdin, err := cmd.StdinPipe()
43+
if err != nil {
44+
return nil, nil, nil, fmt.Errorf("stdin pipe: %w", err)
45+
}
46+
47+
stdout, err := cmd.StdoutPipe()
48+
if err != nil {
49+
stdin.Close()
50+
return nil, nil, nil, fmt.Errorf("stdout pipe: %w", err)
51+
}
52+
53+
// Discard stderr to avoid blocking.
54+
cmd.Stderr = io.Discard
55+
56+
if err := cmd.Start(); err != nil {
57+
stdin.Close()
58+
stdout.Close()
59+
return nil, nil, nil, fmt.Errorf("start claude: %w", err)
60+
}
61+
62+
return stdin, stdout, cmd, nil
63+
}

internal/chatsession/manager.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package chatsession
2+
3+
import (
4+
"crypto/rand"
5+
"fmt"
6+
"sync"
7+
"time"
8+
9+
"github.com/gorilla/websocket"
10+
)
11+
12+
// DefaultManager is the global session registry.
13+
var DefaultManager = NewSessionManager()
14+
15+
// NewSessionManager creates an empty SessionManager.
16+
func NewSessionManager() *SessionManager {
17+
return &SessionManager{
18+
sessions: make(map[string]*ChatSession),
19+
}
20+
}
21+
22+
// Create allocates a new ChatSession in "creating" state.
23+
// The caller must call session.Start(firstMessage) or session.Resume(id) to activate it.
24+
func (m *SessionManager) Create(projectName, projectPath, model string) (*ChatSession, error) {
25+
if projectPath == "" {
26+
return nil, fmt.Errorf("projectPath is required")
27+
}
28+
29+
id := generateID()
30+
31+
session := &ChatSession{
32+
ID: id,
33+
ProjectName: projectName,
34+
ProjectPath: projectPath,
35+
Model: model,
36+
Status: StatusCreating,
37+
CreatedAt: time.Now(),
38+
LastActiveAt: time.Now(),
39+
clients: make(map[*websocket.Conn]bool),
40+
}
41+
42+
m.mu.Lock()
43+
m.sessions[id] = session
44+
m.mu.Unlock()
45+
46+
return session, nil
47+
}
48+
49+
// Get returns a session by ID, or false if not found.
50+
func (m *SessionManager) Get(id string) (*ChatSession, bool) {
51+
m.mu.RLock()
52+
defer m.mu.RUnlock()
53+
s, ok := m.sessions[id]
54+
return s, ok
55+
}
56+
57+
// List returns all active sessions.
58+
func (m *SessionManager) List() []*ChatSession {
59+
m.mu.RLock()
60+
defer m.mu.RUnlock()
61+
62+
result := make([]*ChatSession, 0, len(m.sessions))
63+
for _, s := range m.sessions {
64+
result = append(result, s)
65+
}
66+
return result
67+
}
68+
69+
// Delete closes and removes a session.
70+
func (m *SessionManager) Delete(id string) error {
71+
m.mu.Lock()
72+
s, ok := m.sessions[id]
73+
if !ok {
74+
m.mu.Unlock()
75+
return fmt.Errorf("session %s not found", id)
76+
}
77+
delete(m.sessions, id)
78+
m.mu.Unlock()
79+
80+
return s.Close()
81+
}
82+
83+
// Resume creates a new ChatSession that resumes a previous Claude session.
84+
func (m *SessionManager) Resume(claudeSessionID, projectName, projectPath, model string) (*ChatSession, error) {
85+
session, err := m.Create(projectName, projectPath, model)
86+
if err != nil {
87+
return nil, err
88+
}
89+
90+
if err := session.Resume(claudeSessionID); err != nil {
91+
// Clean up on failure.
92+
m.mu.Lock()
93+
delete(m.sessions, session.ID)
94+
m.mu.Unlock()
95+
return nil, err
96+
}
97+
98+
return session, nil
99+
}
100+
101+
// generateID produces a unique session identifier.
102+
func generateID() string {
103+
var b [4]byte
104+
_, _ = rand.Read(b[:])
105+
return fmt.Sprintf("cs-%d-%x", time.Now().UnixNano(), b)
106+
}
107+
108+
// Snapshot returns a read-only snapshot of a session's public state.
109+
// This avoids exposing the mutex to callers.
110+
func (s *ChatSession) Snapshot() SessionInfo {
111+
s.mu.Lock()
112+
defer s.mu.Unlock()
113+
return SessionInfo{
114+
ID: s.ID,
115+
ProjectName: s.ProjectName,
116+
ProjectPath: s.ProjectPath,
117+
Model: s.Model,
118+
ClaudeSessionID: s.ClaudeSessionID,
119+
Status: s.Status,
120+
CreatedAt: s.CreatedAt,
121+
LastActiveAt: s.LastActiveAt,
122+
CostUSD: s.CostUSD,
123+
TurnCount: s.TurnCount,
124+
ClientCount: len(s.clients),
125+
}
126+
}
127+
128+
// SessionInfo is a read-only view of a ChatSession (safe to serialize).
129+
type SessionInfo struct {
130+
ID string `json:"id"`
131+
ProjectName string `json:"projectName,omitempty"`
132+
ProjectPath string `json:"projectPath"`
133+
Model string `json:"model,omitempty"`
134+
ClaudeSessionID string `json:"claudeSessionId,omitempty"`
135+
Status SessionStatus `json:"status"`
136+
CreatedAt time.Time `json:"createdAt"`
137+
LastActiveAt time.Time `json:"lastActiveAt"`
138+
CostUSD float64 `json:"costUsd"`
139+
TurnCount int `json:"turnCount"`
140+
ClientCount int `json:"clientCount"`
141+
}
142+
143+
// CloseAll shuts down every session. Used during server shutdown.
144+
func (m *SessionManager) CloseAll() {
145+
m.mu.Lock()
146+
ids := make([]string, 0, len(m.sessions))
147+
for id := range m.sessions {
148+
ids = append(ids, id)
149+
}
150+
m.mu.Unlock()
151+
152+
var wg sync.WaitGroup
153+
for _, id := range ids {
154+
wg.Add(1)
155+
go func(sid string) {
156+
defer wg.Done()
157+
m.Delete(sid)
158+
}(id)
159+
}
160+
wg.Wait()
161+
}

0 commit comments

Comments
 (0)