Merge pull request #1446 from trheyi/main
Implement continuation support for Claude CLI commands
This commit is contained in:
commit
5a372300e3
3 changed files with 161 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
@ -727,6 +756,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 +764,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 +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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue