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 <cursoragent@cursor.com>
This commit is contained in:
Max 2026-02-01 13:07:18 +08:00
parent d78b4ea54a
commit 702b743de7
3 changed files with 138 additions and 20 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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