Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 85 additions & 13 deletions agent/sandbox/claude/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,35 @@ When working with GitHub and a token is provided:

// BuildCommand builds the Claude CLI command and environment variables
// Uses stdin with --input-format stream-json for unlimited prompt length
// isContinuation: if true, uses --continue to resume previous session (only sends last user message)
func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) {
// Build system prompt from conversation history
systemPrompt, _ := buildPrompts(messages)
return BuildCommandWithContinuation(messages, opts, false)
}

// Inject sandbox environment prompt
if systemPrompt != "" {
systemPrompt = systemPrompt + "\n\n" + sandboxEnvPrompt
} else {
systemPrompt = sandboxEnvPrompt
// BuildCommandWithContinuation builds the Claude CLI command with continuation support
// isContinuation: if true, uses --continue to resume previous session
func BuildCommandWithContinuation(messages []agentContext.Message, opts *Options, isContinuation bool) ([]string, map[string]string, error) {
// Build system prompt from conversation history (only for first request)
var systemPrompt string
if !isContinuation {
systemPrompt, _ = buildPrompts(messages)
// Inject sandbox environment prompt
if systemPrompt != "" {
systemPrompt = systemPrompt + "\n\n" + sandboxEnvPrompt
} else {
systemPrompt = sandboxEnvPrompt
}
}

// Build input JSONL for Claude CLI (stream-json format)
inputJSONL, err := BuildInputJSONL(messages)
// For continuation, only send the last user message
var inputJSONL []byte
var err error
if isContinuation {
inputJSONL, err = BuildLastUserMessageJSONL(messages)
} else {
inputJSONL, err = BuildFirstRequestJSONL(messages)
}
if err != nil {
return nil, nil, fmt.Errorf("failed to build input JSONL: %w", err)
}
Expand All @@ -80,6 +96,12 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map
claudeArgs = append(claudeArgs, "--include-partial-messages") // Enable realtime streaming
claudeArgs = append(claudeArgs, "--verbose")

// For continuation, use --continue to resume the previous session
// Claude CLI will read session data from $HOME/.claude/ (which is /workspace/.claude/)
if isContinuation {
claudeArgs = append(claudeArgs, "--continue")
}

// Add max_turns if specified
if opts != nil && opts.Arguments != nil {
if maxTurns, ok := opts.Arguments["max_turns"]; ok {
Expand All @@ -99,7 +121,7 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map
// System prompt may contain quotes, newlines, special characters that break shell quoting
var bashCmd strings.Builder

// If we have a system prompt, write it to a temp file via heredoc first
// If we have a system prompt (first request only), write it to a temp file via heredoc first
// then use --append-system-prompt-file
if systemPrompt != "" {
bashCmd.WriteString("cat << 'PROMPTEOF' > /tmp/.system-prompt.txt\n")
Expand Down Expand Up @@ -127,12 +149,18 @@ func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map
}

// BuildInputJSONL converts messages to Claude CLI stream-json input format
// Each message becomes a line in JSONL format
// Deprecated: Use BuildFirstRequestJSONL or BuildLastUserMessageJSONL instead
func BuildInputJSONL(messages []agentContext.Message) ([]byte, error) {
return BuildFirstRequestJSONL(messages)
}

// BuildFirstRequestJSONL builds JSONL for the first request (all messages)
// Sends all user and assistant messages to establish context
func BuildFirstRequestJSONL(messages []agentContext.Message) ([]byte, error) {
var lines []string

for _, msg := range messages {
// Skip system messages (handled via --system-prompt or env var)
// Skip system messages (handled via --system-prompt)
if msg.Role == "system" {
continue
}
Expand All @@ -147,9 +175,9 @@ func BuildInputJSONL(messages []agentContext.Message) ([]byte, error) {

// Create stream-json message
streamMsg := map[string]interface{}{
"type": msg.Role, // "user" or "assistant"
"type": string(msg.Role), // "user" or "assistant"
"message": map[string]interface{}{
"role": msg.Role,
"role": string(msg.Role),
"content": content,
},
}
Expand All @@ -164,6 +192,45 @@ func BuildInputJSONL(messages []agentContext.Message) ([]byte, error) {
return []byte(strings.Join(lines, "\n")), nil
}

// BuildLastUserMessageJSONL builds JSONL with only the last user message
// Used for continuation requests where Claude CLI manages history via --continue
func BuildLastUserMessageJSONL(messages []agentContext.Message) ([]byte, error) {
// Find the last user message
var lastUserMessage *agentContext.Message
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
lastUserMessage = &messages[i]
break
}
}

if lastUserMessage == nil {
return nil, fmt.Errorf("no user message found")
}

var content interface{}
if lastUserMessage.Content != nil {
content = lastUserMessage.Content
} else {
content = ""
}

userMsg := map[string]interface{}{
"type": "user",
"message": map[string]interface{}{
"role": "user",
"content": content,
},
}

jsonBytes, err := json.Marshal(userMsg)
if err != nil {
return nil, fmt.Errorf("failed to marshal user message: %w", err)
}

return jsonBytes, nil
}

// buildPrompts extracts system prompt and user prompt from messages
func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) {
var systemParts []string
Expand Down Expand Up @@ -234,6 +301,11 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
return env
}

// Set HOME to /workspace so Claude CLI stores session data in the workspace
// This allows session persistence across requests for the same chat
// Session data is stored in $HOME/.claude/ (i.e., /workspace/.claude/)
env["HOME"] = "/workspace"

// claude-proxy runs on localhost:3456, Claude CLI connects to it
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this
Expand Down
21 changes: 20 additions & 1 deletion agent/sandbox/claude/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,11 @@ func (e *Executor) Stream(ctx *agentContext.Context, messages []agentContext.Mes
}, nil
}

// Check if this is a continuation (Claude CLI session exists in workspace)
isContinuation := e.hasExistingSession(stdCtx)

// Build Claude CLI command using stored options
cmd, env, err := BuildCommand(messages, e.opts)
cmd, env, err := BuildCommandWithContinuation(messages, e.opts, isContinuation)
if err != nil {
return nil, fmt.Errorf("failed to build command: %w", err)
}
Expand Down Expand Up @@ -262,6 +265,22 @@ func (e *Executor) shouldSkipClaudeCLI() bool {
return !hasPrompts && !hasSkills && !hasMCP
}

// hasExistingSession checks if Claude CLI has an existing session in the workspace
// Claude CLI stores session data in $HOME/.claude/projects/ (which is /workspace/.claude/projects/)
// If session data exists, we should use --continue to resume the session
func (e *Executor) hasExistingSession(ctx context.Context) bool {
// Check if /workspace/.claude/projects/ directory has any content
// This indicates a previous session exists
sessionDir := e.workDir + "/.claude/projects"
files, err := e.manager.ListDir(ctx, e.containerName, sessionDir)
if err != nil {
// Directory doesn't exist or error reading - no existing session
return false
}
// If there are any files/directories in the projects folder, session exists
return len(files) > 0
}

// prepareEnvironment prepares the container environment before execution
// This includes: claude-proxy config, MCP config, and Skills directory
func (e *Executor) prepareEnvironment(ctx context.Context) error {
Expand Down
75 changes: 56 additions & 19 deletions sandbox/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,24 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont
if c, ok := m.containers.Load(name); ok {
cont := c.(*Container)
cont.LastUsedAt = time.Now()
// Ensure IPC session exists (may have been closed)
m.ensureIPCSession(ctx, userID, chatID)
return cont, nil

// Verify container actually exists in Docker
// (container may have been removed externally or Docker restarted)
_, err := m.dockerClient.ContainerInspect(ctx, cont.ID)
if err != nil {
// Container no longer exists in Docker, remove from cache and recreate
m.containers.Delete(name)
m.mu.Lock()
if m.running > 0 {
m.running--
}
m.mu.Unlock()
// Fall through to create new container
} else {
// Container exists, ensure IPC session exists (may have been closed)
m.ensureIPCSession(ctx, userID, chatID)
return cont, nil
}
}

// Use mutex for creation to avoid race condition
Expand All @@ -203,9 +218,21 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont
if c, ok := m.containers.Load(name); ok {
cont := c.(*Container)
cont.LastUsedAt = time.Now()
// Ensure IPC session exists (may have been closed)
m.ensureIPCSession(ctx, userID, chatID)
return cont, nil

// Verify container actually exists in Docker
_, err := m.dockerClient.ContainerInspect(ctx, cont.ID)
if err != nil {
// Container no longer exists in Docker, remove from cache
m.containers.Delete(name)
if m.running > 0 {
m.running--
}
// Fall through to create new container
} else {
// Container exists, ensure IPC session exists (may have been closed)
m.ensureIPCSession(ctx, userID, chatID)
return cont, nil
}
}

// Check running container limit
Expand Down Expand Up @@ -648,8 +675,10 @@ func (m *Manager) Cleanup(ctx context.Context) error {
name := key.(string)
c := value.(*Container)

idleTime := now.Sub(c.LastUsedAt)

// Stop idle containers
if c.Status == StatusRunning && now.Sub(c.LastUsedAt) > m.config.IdleTimeout {
if c.Status == StatusRunning && idleTime > m.config.IdleTimeout {
m.Stop(ctx, name)
}

Expand Down Expand Up @@ -727,27 +756,21 @@ func (m *Manager) WriteFile(ctx context.Context, name, path string, content []by
}

// ReadFile reads content from a file in container
// Since workspace is bind-mounted, we read directly from host for better performance
func (m *Manager) ReadFile(ctx context.Context, name, path string) ([]byte, error) {
c, ok := m.containers.Load(name)
if !ok {
return nil, ErrContainerNotFound
}
cont := c.(*Container)

reader, _, err := m.dockerClient.CopyFromContainer(ctx, cont.ID, path)
if err != nil {
return nil, err
}
defer reader.Close()

// Extract from tar
tr := tar.NewReader(reader)
_, err = tr.Next()
if err != nil {
return nil, err
// Read directly from host bind mount
hostPath := m.containerPathToHost(cont, path)
if hostPath == "" {
return nil, fmt.Errorf("path %s is not within workspace", path)
}

return io.ReadAll(tr)
return os.ReadFile(hostPath)
}

// ListDir lists directory contents in container
Expand Down Expand Up @@ -840,6 +863,20 @@ func (m *Manager) ensureIPCSession(ctx context.Context, userID, chatID string) {
m.ipcManager.Create(ctx, sessionID, agentCtx, nil)
}

// containerPathToHost converts a container path to the corresponding host path
// Returns empty string if the path is not within a bind-mounted directory
func (m *Manager) containerPathToHost(cont *Container, containerPath string) string {
// Container workspace is mounted at ContainerWorkDir (e.g., /workspace)
// Host path is WorkspaceRoot/{userID}/{chatID}
workDir := m.config.ContainerWorkDir
if strings.HasPrefix(containerPath, workDir) {
relativePath := strings.TrimPrefix(containerPath, workDir)
relativePath = strings.TrimPrefix(relativePath, "/")
return filepath.Join(m.config.WorkspaceRoot, cont.UserID, cont.ChatID, relativePath)
}
return ""
}

// fixIPCSocketPermissions fixes IPC socket permissions inside the container
// This is needed because macOS Docker Desktop with gRPC-FUSE doesn't properly
// preserve Unix socket permissions when bind mounting from host.
Expand Down