Files
cc-connect/agent/claudecode/session.go
T
chenhg5andClaude 8818d3bed1 refactor(agent): optimize scanner buffer sizes
Updated buffer sizes for stdout scanners across all agent sessions:
- Initial buffer size reduced from 1MB to 64KB
- Max buffer size increased from 1MB to 10MB

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-12 06:34:02 +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, 0, 64*1024), 10*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
}