Files
cc-connect/agent/claudecode/session.go
T
chenhg5andClaude f2920739d8 feat: add timeout handling for agent session closure
Added timeout mechanisms with logging for graceful session closure across multiple agent types (claudecode, codex, cursor, gemini, opencode, qoder). Modified engine cleanup to handle timeouts and improve logging. Sessions now timeout after 8-10 seconds with appropriate warnings/errors.

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-04 22:02:54 +08:00

494 lines
12 KiB
Go

package claudecode
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/chenhg5/cc-connect/core"
)
// claudeSession manages a long-running Claude Code process using
// --input-format stream-json and --permission-prompt-tool stdio.
//
// In "auto" mode, permission requests are auto-approved internally
// (avoiding --dangerously-skip-permissions which fails under root).
type claudeSession struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdinMu sync.Mutex
events chan core.Event
sessionID atomic.Value // stores string
autoApprove bool // auto mode: approve all permission requests
workDir string
ctx context.Context
cancel context.CancelFunc
done chan struct{}
alive atomic.Bool
}
func newClaudeSession(ctx context.Context, workDir, model, sessionID, mode string, allowedTools []string, extraEnv []string) (*claudeSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
args := []string{
"--output-format", "stream-json",
"--verbose",
"--input-format", "stream-json",
"--permission-prompt-tool", "stdio",
}
if mode != "" && mode != "default" {
args = append(args, "--permission-mode", mode)
}
if sessionID != "" {
args = append(args, "--resume", sessionID)
}
if model != "" {
args = append(args, "--model", model)
}
if len(allowedTools) > 0 {
args = append(args, "--allowedTools", strings.Join(allowedTools, ","))
}
if sysPrompt := core.AgentSystemPrompt(); sysPrompt != "" {
args = append(args, "--append-system-prompt", sysPrompt)
}
slog.Debug("claudeSession: starting", "args", core.RedactArgs(args), "dir", workDir, "mode", mode)
cmd := exec.CommandContext(sessionCtx, "claude", args...)
cmd.Dir = workDir
// Filter out CLAUDECODE env var to prevent "nested session" detection,
// since cc-connect is a bridge, not a nested Claude Code session.
env := filterEnv(os.Environ(), "CLAUDECODE")
if len(extraEnv) > 0 {
env = core.MergeEnv(env, extraEnv)
}
cmd.Env = env
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("claudeSession: stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("claudeSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("claudeSession: start: %w", err)
}
cs := &claudeSession{
cmd: cmd,
stdin: stdin,
events: make(chan core.Event, 64),
autoApprove: mode == "bypassPermissions",
workDir: workDir,
ctx: sessionCtx,
cancel: cancel,
done: make(chan struct{}),
}
cs.sessionID.Store(sessionID)
cs.alive.Store(true)
go cs.readLoop(stdout, &stderrBuf)
return cs, nil
}
func (cs *claudeSession) readLoop(stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
defer func() {
cs.alive.Store(false)
if err := cs.cmd.Wait(); err != nil {
stderrMsg := strings.TrimSpace(stderrBuf.String())
if stderrMsg != "" {
slog.Error("claudeSession: process failed", "error", err, "stderr", stderrMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
close(cs.events)
close(cs.done)
}()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var raw map[string]any
if err := json.Unmarshal([]byte(line), &raw); err != nil {
slog.Debug("claudeSession: non-JSON line", "line", line)
continue
}
eventType, _ := raw["type"].(string)
slog.Debug("claudeSession: event", "type", eventType)
switch eventType {
case "system":
cs.handleSystem(raw)
case "assistant":
cs.handleAssistant(raw)
case "user":
cs.handleUser(raw)
case "result":
cs.handleResult(raw)
case "control_request":
cs.handleControlRequest(raw)
case "control_cancel_request":
requestID, _ := raw["request_id"].(string)
slog.Debug("claudeSession: permission cancelled", "request_id", requestID)
}
}
if err := scanner.Err(); err != nil {
slog.Error("claudeSession: scanner error", "error", err)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
func (cs *claudeSession) handleSystem(raw map[string]any) {
if sid, ok := raw["session_id"].(string); ok && sid != "" {
cs.sessionID.Store(sid)
evt := core.Event{Type: core.EventText, SessionID: sid}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
func (cs *claudeSession) handleAssistant(raw map[string]any) {
msg, ok := raw["message"].(map[string]any)
if !ok {
return
}
contentArr, ok := msg["content"].([]any)
if !ok {
return
}
for _, contentItem := range contentArr {
item, ok := contentItem.(map[string]any)
if !ok {
continue
}
contentType, _ := item["type"].(string)
switch contentType {
case "tool_use":
toolName, _ := item["name"].(string)
inputSummary := summarizeInput(toolName, item["input"])
evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: inputSummary}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
case "thinking":
if thinking, ok := item["thinking"].(string); ok && thinking != "" {
evt := core.Event{Type: core.EventThinking, Content: thinking}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
case "text":
if text, ok := item["text"].(string); ok && text != "" {
evt := core.Event{Type: core.EventText, Content: text}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
}
}
func (cs *claudeSession) handleUser(raw map[string]any) {
msg, ok := raw["message"].(map[string]any)
if !ok {
return
}
contentArr, ok := msg["content"].([]any)
if !ok {
return
}
for _, contentItem := range contentArr {
item, ok := contentItem.(map[string]any)
if !ok {
continue
}
contentType, _ := item["type"].(string)
if contentType == "tool_result" {
isError, _ := item["is_error"].(bool)
if isError {
result, _ := item["content"].(string)
slog.Debug("claudeSession: tool error", "content", result)
}
}
}
}
func (cs *claudeSession) handleResult(raw map[string]any) {
var content string
if result, ok := raw["result"].(string); ok {
content = result
}
if sid, ok := raw["session_id"].(string); ok && sid != "" {
cs.sessionID.Store(sid)
}
evt := core.Event{Type: core.EventResult, Content: content, SessionID: cs.CurrentSessionID(), Done: true}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
func (cs *claudeSession) handleControlRequest(raw map[string]any) {
requestID, _ := raw["request_id"].(string)
request, _ := raw["request"].(map[string]any)
if request == nil {
return
}
subtype, _ := request["subtype"].(string)
if subtype != "can_use_tool" {
slog.Debug("claudeSession: unknown control request subtype", "subtype", subtype)
return
}
toolName, _ := request["tool_name"].(string)
input, _ := request["input"].(map[string]any)
// Auto mode: approve immediately without asking the user
if cs.autoApprove {
slog.Debug("claudeSession: auto-approving", "request_id", requestID, "tool", toolName)
_ = cs.RespondPermission(requestID, core.PermissionResult{
Behavior: "allow",
UpdatedInput: input,
})
return
}
slog.Info("claudeSession: permission request", "request_id", requestID, "tool", toolName)
evt := core.Event{
Type: core.EventPermissionRequest,
RequestID: requestID,
ToolName: toolName,
ToolInput: summarizeInput(toolName, input),
ToolInputRaw: input,
}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
// Send writes a user message (with optional images) to the Claude process stdin.
// Images are saved to local temp files first, then sent as base64 in the
// multimodal content array. File paths are also mentioned in the text prompt
// as a fallback so Claude Code can read them with its built-in tools.
func (cs *claudeSession) Send(prompt string, images []core.ImageAttachment) error {
if !cs.alive.Load() {
return fmt.Errorf("session process is not running")
}
if len(images) == 0 {
return cs.writeJSON(map[string]any{
"type": "user",
"message": map[string]any{"role": "user", "content": prompt},
})
}
// Save images to local files and build multimodal content
imgDir := filepath.Join(cs.workDir, ".cc-connect", "images")
os.MkdirAll(imgDir, 0o755)
var parts []map[string]any
var savedPaths []string
for i, img := range images {
ext := extFromMime(img.MimeType)
fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
fpath := filepath.Join(imgDir, fname)
if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
slog.Error("claudeSession: save image failed", "error", err)
continue
}
savedPaths = append(savedPaths, fpath)
slog.Debug("claudeSession: image saved", "path", fpath, "size", len(img.Data))
mimeType := img.MimeType
if mimeType == "" {
mimeType = "image/png"
}
parts = append(parts, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": mimeType,
"data": base64.StdEncoding.EncodeToString(img.Data),
},
})
}
// Build text part: user prompt + file path references as fallback
textPart := prompt
if textPart == "" {
textPart = "Please analyze the attached image(s)."
}
if len(savedPaths) > 0 {
textPart += "\n\n(Images also saved locally: " + strings.Join(savedPaths, ", ") + ")"
}
parts = append(parts, map[string]any{"type": "text", "text": textPart})
return cs.writeJSON(map[string]any{
"type": "user",
"message": map[string]any{"role": "user", "content": parts},
})
}
func extFromMime(mime string) string {
switch mime {
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
default:
return ".png"
}
}
// RespondPermission writes a control_response to the Claude process stdin.
func (cs *claudeSession) RespondPermission(requestID string, result core.PermissionResult) error {
if !cs.alive.Load() {
return fmt.Errorf("session process is not running")
}
var permResponse map[string]any
if result.Behavior == "allow" {
updatedInput := result.UpdatedInput
if updatedInput == nil {
updatedInput = make(map[string]any)
}
permResponse = map[string]any{
"behavior": "allow",
"updatedInput": updatedInput,
}
} else {
msg := result.Message
if msg == "" {
msg = "The user denied this tool use. Stop and wait for the user's instructions."
}
permResponse = map[string]any{
"behavior": "deny",
"message": msg,
}
}
controlResponse := map[string]any{
"type": "control_response",
"response": map[string]any{
"subtype": "success",
"request_id": requestID,
"response": permResponse,
},
}
slog.Debug("claudeSession: permission response", "request_id", requestID, "behavior", result.Behavior)
return cs.writeJSON(controlResponse)
}
func (cs *claudeSession) writeJSON(v any) error {
cs.stdinMu.Lock()
defer cs.stdinMu.Unlock()
data, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
if _, err := cs.stdin.Write(append(data, '\n')); err != nil {
return fmt.Errorf("write stdin: %w", err)
}
return nil
}
func (cs *claudeSession) Events() <-chan core.Event {
return cs.events
}
func (cs *claudeSession) CurrentSessionID() string {
v, _ := cs.sessionID.Load().(string)
return v
}
func (cs *claudeSession) Alive() bool {
return cs.alive.Load()
}
func (cs *claudeSession) Close() error {
cs.cancel()
select {
case <-cs.done:
return nil
case <-time.After(8 * time.Second):
slog.Warn("claudeSession: graceful close timed out, killing process")
if cs.cmd != nil && cs.cmd.Process != nil {
_ = cs.cmd.Process.Kill()
}
<-cs.done
return nil
}
}
// filterEnv returns a copy of env with entries matching the given key removed.
func filterEnv(env []string, key string) []string {
prefix := key + "="
out := make([]string, 0, len(env))
for _, e := range env {
if !strings.HasPrefix(e, prefix) {
out = append(out, e)
}
}
return out
}