From d78b4ea54ad75bf414fa1aa8075a34507acefe30 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 1 Feb 2026 11:24:47 +0800 Subject: [PATCH 1/2] Refactor container file operations for improved performance - Update ReadFile method to read directly from the host's bind-mounted workspace instead of using Docker's CopyFromContainer, enhancing performance. - Introduce containerPathToHost method to convert container paths to host paths, ensuring proper access to bind-mounted directories. - Simplify idle container cleanup logic by storing the idle time in a variable for better readability. Co-authored-by: Cursor --- sandbox/manager.go | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/sandbox/manager.go b/sandbox/manager.go index c93c6a59..484d059e 100644 --- a/sandbox/manager.go +++ b/sandbox/manager.go @@ -648,8 +648,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) } @@ -727,6 +729,7 @@ 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 { @@ -734,20 +737,13 @@ func (m *Manager) ReadFile(ctx context.Context, name, path string) ([]byte, erro } 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 @@ -840,6 +836,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. From 702b743de7c112c31463960499b60e473e4adfb5 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 1 Feb 2026 13:07:18 +0800 Subject: [PATCH 2/2] Implement continuation support for Claude CLI commands - Refactor BuildCommand to support session continuation using --continue flag. - Introduce BuildCommandWithContinuation to handle both initial and continuation requests. - Add BuildLastUserMessageJSONL for generating JSONL from the last user message in continuation scenarios. - Enhance Executor to check for existing sessions and adjust command building accordingly. - Update environment setup to ensure session data is stored correctly for persistence. Co-authored-by: Cursor --- agent/sandbox/claude/command.go | 98 +++++++++++++++++++++++++++----- agent/sandbox/claude/executor.go | 21 ++++++- sandbox/manager.go | 39 +++++++++++-- 3 files changed, 138 insertions(+), 20 deletions(-) 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