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
|
// BuildCommand builds the Claude CLI command and environment variables
|
||||||
// Uses stdin with --input-format stream-json for unlimited prompt length
|
// 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) {
|
func BuildCommand(messages []agentContext.Message, opts *Options) ([]string, map[string]string, error) {
|
||||||
// Build system prompt from conversation history
|
return BuildCommandWithContinuation(messages, opts, false)
|
||||||
systemPrompt, _ := buildPrompts(messages)
|
}
|
||||||
|
|
||||||
|
// 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
|
// Inject sandbox environment prompt
|
||||||
if systemPrompt != "" {
|
if systemPrompt != "" {
|
||||||
systemPrompt = systemPrompt + "\n\n" + sandboxEnvPrompt
|
systemPrompt = systemPrompt + "\n\n" + sandboxEnvPrompt
|
||||||
} else {
|
} else {
|
||||||
systemPrompt = sandboxEnvPrompt
|
systemPrompt = sandboxEnvPrompt
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build input JSONL for Claude CLI (stream-json format)
|
// 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 {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to build input JSONL: %w", err)
|
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, "--include-partial-messages") // Enable realtime streaming
|
||||||
claudeArgs = append(claudeArgs, "--verbose")
|
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
|
// Add max_turns if specified
|
||||||
if opts != nil && opts.Arguments != nil {
|
if opts != nil && opts.Arguments != nil {
|
||||||
if maxTurns, ok := opts.Arguments["max_turns"]; ok {
|
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
|
// System prompt may contain quotes, newlines, special characters that break shell quoting
|
||||||
var bashCmd strings.Builder
|
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
|
// then use --append-system-prompt-file
|
||||||
if systemPrompt != "" {
|
if systemPrompt != "" {
|
||||||
bashCmd.WriteString("cat << 'PROMPTEOF' > /tmp/.system-prompt.txt\n")
|
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
|
// 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) {
|
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
|
var lines []string
|
||||||
|
|
||||||
for _, msg := range messages {
|
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" {
|
if msg.Role == "system" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -147,9 +175,9 @@ func BuildInputJSONL(messages []agentContext.Message) ([]byte, error) {
|
||||||
|
|
||||||
// Create stream-json message
|
// Create stream-json message
|
||||||
streamMsg := map[string]interface{}{
|
streamMsg := map[string]interface{}{
|
||||||
"type": msg.Role, // "user" or "assistant"
|
"type": string(msg.Role), // "user" or "assistant"
|
||||||
"message": map[string]interface{}{
|
"message": map[string]interface{}{
|
||||||
"role": msg.Role,
|
"role": string(msg.Role),
|
||||||
"content": content,
|
"content": content,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -164,6 +192,45 @@ func BuildInputJSONL(messages []agentContext.Message) ([]byte, error) {
|
||||||
return []byte(strings.Join(lines, "\n")), nil
|
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
|
// buildPrompts extracts system prompt and user prompt from messages
|
||||||
func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) {
|
func buildPrompts(messages []agentContext.Message) (systemPrompt string, userPrompt string) {
|
||||||
var systemParts []string
|
var systemParts []string
|
||||||
|
|
@ -234,6 +301,11 @@ func buildEnvironment(opts *Options, systemPrompt string) map[string]string {
|
||||||
return env
|
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
|
// claude-proxy runs on localhost:3456, Claude CLI connects to it
|
||||||
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
|
env["ANTHROPIC_BASE_URL"] = "http://127.0.0.1:3456"
|
||||||
env["ANTHROPIC_API_KEY"] = "dummy" // Proxy doesn't verify this
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a continuation (Claude CLI session exists in workspace)
|
||||||
|
isContinuation := e.hasExistingSession(stdCtx)
|
||||||
|
|
||||||
// Build Claude CLI command using stored options
|
// 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to build command: %w", err)
|
return nil, fmt.Errorf("failed to build command: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -262,6 +265,22 @@ func (e *Executor) shouldSkipClaudeCLI() bool {
|
||||||
return !hasPrompts && !hasSkills && !hasMCP
|
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
|
// prepareEnvironment prepares the container environment before execution
|
||||||
// This includes: claude-proxy config, MCP config, and Skills directory
|
// This includes: claude-proxy config, MCP config, and Skills directory
|
||||||
func (e *Executor) prepareEnvironment(ctx context.Context) error {
|
func (e *Executor) prepareEnvironment(ctx context.Context) error {
|
||||||
|
|
|
||||||
|
|
@ -190,10 +190,25 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont
|
||||||
if c, ok := m.containers.Load(name); ok {
|
if c, ok := m.containers.Load(name); ok {
|
||||||
cont := c.(*Container)
|
cont := c.(*Container)
|
||||||
cont.LastUsedAt = time.Now()
|
cont.LastUsedAt = time.Now()
|
||||||
// Ensure IPC session exists (may have been closed)
|
|
||||||
|
// 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)
|
m.ensureIPCSession(ctx, userID, chatID)
|
||||||
return cont, nil
|
return cont, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use mutex for creation to avoid race condition
|
// Use mutex for creation to avoid race condition
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|
@ -203,10 +218,22 @@ func (m *Manager) GetOrCreate(ctx context.Context, userID, chatID string) (*Cont
|
||||||
if c, ok := m.containers.Load(name); ok {
|
if c, ok := m.containers.Load(name); ok {
|
||||||
cont := c.(*Container)
|
cont := c.(*Container)
|
||||||
cont.LastUsedAt = time.Now()
|
cont.LastUsedAt = time.Now()
|
||||||
// Ensure IPC session exists (may have been closed)
|
|
||||||
|
// 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)
|
m.ensureIPCSession(ctx, userID, chatID)
|
||||||
return cont, nil
|
return cont, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check running container limit
|
// Check running container limit
|
||||||
if m.running >= int32(m.config.MaxContainers) {
|
if m.running >= int32(m.config.MaxContainers) {
|
||||||
|
|
@ -648,8 +675,10 @@ func (m *Manager) Cleanup(ctx context.Context) error {
|
||||||
name := key.(string)
|
name := key.(string)
|
||||||
c := value.(*Container)
|
c := value.(*Container)
|
||||||
|
|
||||||
|
idleTime := now.Sub(c.LastUsedAt)
|
||||||
|
|
||||||
// Stop idle containers
|
// 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)
|
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
|
// 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) {
|
func (m *Manager) ReadFile(ctx context.Context, name, path string) ([]byte, error) {
|
||||||
c, ok := m.containers.Load(name)
|
c, ok := m.containers.Load(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -734,20 +764,13 @@ func (m *Manager) ReadFile(ctx context.Context, name, path string) ([]byte, erro
|
||||||
}
|
}
|
||||||
cont := c.(*Container)
|
cont := c.(*Container)
|
||||||
|
|
||||||
reader, _, err := m.dockerClient.CopyFromContainer(ctx, cont.ID, path)
|
// Read directly from host bind mount
|
||||||
if err != nil {
|
hostPath := m.containerPathToHost(cont, path)
|
||||||
return nil, err
|
if hostPath == "" {
|
||||||
}
|
return nil, fmt.Errorf("path %s is not within workspace", path)
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
// Extract from tar
|
|
||||||
tr := tar.NewReader(reader)
|
|
||||||
_, err = tr.Next()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return io.ReadAll(tr)
|
return os.ReadFile(hostPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListDir lists directory contents in container
|
// 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)
|
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
|
// fixIPCSocketPermissions fixes IPC socket permissions inside the container
|
||||||
// This is needed because macOS Docker Desktop with gRPC-FUSE doesn't properly
|
// This is needed because macOS Docker Desktop with gRPC-FUSE doesn't properly
|
||||||
// preserve Unix socket permissions when bind mounting from host.
|
// preserve Unix socket permissions when bind mounting from host.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue