diff --git a/agent/sandbox/claude/command.go b/agent/sandbox/claude/command.go index 37cdca09..0290a36e 100644 --- a/agent/sandbox/claude/command.go +++ b/agent/sandbox/claude/command.go @@ -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) } @@ -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 { @@ -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") @@ -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 } @@ -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, }, } @@ -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 @@ -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 diff --git a/agent/sandbox/claude/executor.go b/agent/sandbox/claude/executor.go index e028c919..158bbfd4 100644 --- a/agent/sandbox/claude/executor.go +++ b/agent/sandbox/claude/executor.go @@ -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) } @@ -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 { diff --git a/sandbox/manager.go b/sandbox/manager.go index 484d059e..056b99bc 100644 --- a/sandbox/manager.go +++ b/sandbox/manager.go @@ -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 @@ -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