package claudecode import ( "bufio" "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "log/slog" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "sync/atomic" "syscall" "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 permissionMode atomic.Value // stores string autoApprove atomic.Bool acceptEditsOnly atomic.Bool dontAsk atomic.Bool workDir string ctx context.Context cancel context.CancelFunc done chan struct{} alive atomic.Bool // gracefulStopTimeout is how long Close() waits for a clean exit // (stdin close → Stop hooks → process exit) before escalating to // SIGTERM and then SIGKILL. Default: 120s to match claude-mem's // Stop hook timeout. The wait ends as soon as the process exits, // so typical shutdowns take seconds, not the full timeout. gracefulStopTimeout time.Duration } func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs []string, cliArgsFlag string, model, effort, sessionID, mode, systemPrompt string, allowedTools, disallowedTools []string, extraEnv []string, platformPrompt string, disableVerbose bool, spawnOpts core.SpawnOptions, maxContextTokens int) (*claudeSession, error) { sessionCtx, cancel := context.WithCancel(ctx) // innerArgs are Claude Code CLI flags — when a wrapper is used with // cliArgsFlag these get bundled into a single passthrough string. // outerArgs are flags the wrapper itself understands (e.g. --model). innerArgs := []string{ "--output-format", "stream-json", "--input-format", "stream-json", "--permission-prompt-tool", "stdio", "--replay-user-messages", } if !disableVerbose { innerArgs = append(innerArgs, "--verbose") } if mode != "" && mode != "default" { innerArgs = append(innerArgs, "--permission-mode", mode) } switch sessionID { case "", core.ContinueSession: // Truly fresh session — no resume, no continue. default: // Resuming a known session ID — this is cc-connect's own session // from a previous connection, safe to resume directly. innerArgs = append(innerArgs, "--resume", sessionID) } if len(allowedTools) > 0 { innerArgs = append(innerArgs, "--allowedTools", strings.Join(allowedTools, ",")) } if len(disallowedTools) > 0 { innerArgs = append(innerArgs, "--disallowedTools", strings.Join(disallowedTools, ",")) } // Handle custom system prompt if systemPrompt != "" { innerArgs = append(innerArgs, "--system-prompt", systemPrompt) } // Always append cc-connect system prompt for functionality awareness if sysPrompt := core.AgentSystemPrompt(); sysPrompt != "" { if platformPrompt != "" { sysPrompt += "\n## Formatting\n" + platformPrompt + "\n" } innerArgs = append(innerArgs, "--append-system-prompt", sysPrompt) } if effort != "" { innerArgs = append(innerArgs, "--effort", effort) } if maxContextTokens > 0 { innerArgs = append(innerArgs, "--max-context-tokens", strconv.Itoa(maxContextTokens)) } // outerArgs are understood by both the wrapper and Claude CLI directly. var outerArgs []string if model != "" { outerArgs = append(outerArgs, "--model", model) } slog.Debug("claudeSession: starting", "innerArgs", core.RedactArgs(innerArgs), "outerArgs", core.RedactArgs(outerArgs), "dir", workDir, "mode", mode, "run_as_user", spawnOpts.RunAsUser) // Per-spawn defense in depth: if run_as_user is set, re-run the cheap // preflight (sudo still works + target still can't escalate) right // before we build the command. This catches sudoers being edited // between startup preflight and now. if spawnOpts.IsolationMode() { verifyCtx, verifyCancel := context.WithTimeout(sessionCtx, 10*time.Second) err := core.VerifyRunAsUserCheap(verifyCtx, core.ExecSudoRunner{}, spawnOpts.RunAsUser) verifyCancel() if err != nil { cancel() return nil, fmt.Errorf("claudeSession: run_as_user spawn refused: %w", err) } } // Build final argument list. // When cliArgsFlag is set (e.g. "-a"), inner args are bundled into a // single passthrough string via that flag, while outer args (--model etc.) // are appended directly so the wrapper can also interpret them. // Args containing spaces/newlines are quoted so the wrapper's command-line // parser (e.g. splitCommandLine) keeps them as single tokens. // Result: my-cli code -t foo -a "--verbose --append-system-prompt 'long text'" --model x var allArgs []string if cliArgsFlag != "" { allArgs = append(allArgs, cliExtraArgs...) allArgs = append(allArgs, cliArgsFlag, shellJoinArgs(innerArgs)) allArgs = append(allArgs, outerArgs...) } else { allArgs = append(allArgs, cliExtraArgs...) allArgs = append(allArgs, innerArgs...) allArgs = append(allArgs, outerArgs...) } cmd := core.BuildSpawnCommand(sessionCtx, spawnOpts, cliBin, allArgs...) cmd.Dir = workDir // Put the child into its own process group so Close() can terminate the // entire descendant tree (claude CLI → MCP server bridges → ...) with a // single signal. Without this, killing only the direct child can leave // MCP grandchildren (e.g. the Telegram bridge bun process) spinning at // 100% CPU after their parent's stdio pipe closes. prepareCmdForKill(cmd) // 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) } // When run_as_user is set, strip the supervisor's environment down to // the allowlist before passing it to sudo. sudo --preserve-env also // enforces this, but filtering here makes the cc-connect spawn argv // the single source of truth. env = core.FilterEnvForSpawn(env, spawnOpts) cmd.Env = env var providerEnvSnapshot []string for _, e := range env { for _, prefix := range []string{"ANTHROPIC_", "CLAUDE_", "AWS_", "NO_PROXY", "DISABLE_"} { if strings.HasPrefix(e, prefix) { providerEnvSnapshot = append(providerEnvSnapshot, e) break } } } slog.Debug("claudeSession: spawn details", "bin", cliBin, "allArgs", core.RedactArgs(allArgs), "model", model, "providerEnv", core.RedactEnv(providerEnvSnapshot)) 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), workDir: workDir, ctx: sessionCtx, cancel: cancel, done: make(chan struct{}), gracefulStopTimeout: 120 * time.Second, } cs.setPermissionMode(mode) 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) { waitErrCh, waitDone := cs.startReadLoopWait(stdout) defer cs.finishReadLoop(waitErrCh, stderrBuf) scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) for scanner.Scan() { cs.handleReadLoopLine(scanner.Text()) } cs.handleReadLoopScanErr(scanner.Err(), waitDone) } func (cs *claudeSession) startReadLoopWait(stdout io.ReadCloser) (<-chan error, <-chan struct{}) { waitErrCh := make(chan error, 1) waitDone := make(chan struct{}) go func() { waitErrCh <- cs.cmd.Wait() close(waitDone) }() go func() { select { case <-cs.ctx.Done(): _ = stdout.Close() return case <-waitDone: } // Grace period: give scanner a brief window to drain any data the // agent wrote to the pipe buffer before exiting. If scanner finishes // on its own (pipe fully closed, no descendants holding it), // cs.done fires first and we skip the force-close entirely select { case <-cs.done: return case <-time.After(50 * time.Millisecond): } _ = stdout.Close() }() return waitErrCh, waitDone } func (cs *claudeSession) finishReadLoop(waitErrCh <-chan error, stderrBuf *bytes.Buffer) { err := <-waitErrCh cs.alive.Store(false) if err != nil { stderrMsg := "" if stderrBuf != 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(): // INVARIANT: readLoop must close cs.events and cs.done exactly once // on every termination path. Callers (engine event loop) rely on // these closures to observe session end. } } } close(cs.events) close(cs.done) } func (cs *claudeSession) handleReadLoopScanErr(err error, waitDone <-chan struct{}) { if err == nil { return } select { case <-cs.ctx.Done(): return case <-waitDone: return default: } 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) handleReadLoopLine(line string) { if line == "" { return } var raw map[string]any if err := json.Unmarshal([]byte(line), &raw); err != nil { slog.Debug("claudeSession: non-JSON line", "line", line) return } 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) } } 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) if toolName == "AskUserQuestion" { continue } 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) } var inputTokens, outputTokens int if usage, ok := raw["usage"].(map[string]any); ok { if v, ok := usage["input_tokens"].(float64); ok { inputTokens = int(v) } if v, ok := usage["output_tokens"].(float64); ok { outputTokens = int(v) } } evt := core.Event{ Type: core.EventResult, Content: content, SessionID: cs.CurrentSessionID(), Done: true, InputTokens: inputTokens, OutputTokens: outputTokens, } 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) if cs.autoApprove.Load() { slog.Debug("claudeSession: auto-approving", "request_id", requestID, "tool", toolName) _ = cs.RespondPermission(requestID, core.PermissionResult{ Behavior: "allow", UpdatedInput: input, }) return } if cs.dontAsk.Load() { slog.Debug("claudeSession: auto-denying", "request_id", requestID, "tool", toolName) _ = cs.RespondPermission(requestID, core.PermissionResult{ Behavior: "deny", Message: "Permission mode is set to dontAsk.", }) return } if cs.acceptEditsOnly.Load() && isClaudeEditTool(toolName) { slog.Debug("claudeSession: auto-approving edit tool", "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, } if toolName == "AskUserQuestion" { evt.Questions = parseUserQuestions(input) } select { case cs.events <- evt: case <-cs.ctx.Done(): return } } // Send writes a user message (with optional images and files) to the Claude process stdin. // Images are sent as base64 in the multimodal content array. // Files are saved to local temp files and referenced in the text prompt // so Claude Code can read them with its built-in tools. func (cs *claudeSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { if !cs.alive.Load() { return fmt.Errorf("session process is not running") } if len(images) == 0 && len(files) == 0 { return cs.writeJSON(map[string]any{ "type": "user", "message": map[string]any{"role": "user", "content": prompt}, }) } attachDir := filepath.Join(cs.workDir, ".cc-connect", "attachments") if err := os.MkdirAll(attachDir, 0o755); err != nil { slog.Warn("claudeSession: mkdir attachments failed", "error", err, "path", attachDir) } var parts []map[string]any var savedPaths []string // Save and encode images for i, img := range images { ext := extFromMime(img.MimeType) fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext) fpath := filepath.Join(attachDir, 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), }, }) } // Save files to disk so Claude Code can read them filePaths := core.SaveFilesToDisk(cs.workDir, files) // Build text part: user prompt + file path references textPart := prompt if textPart == "" && len(filePaths) > 0 { textPart = "Please analyze the attached file(s)." } else if textPart == "" { textPart = "Please analyze the attached image(s)." } if len(savedPaths) > 0 { textPart += "\n\n(Images also saved locally: " + strings.Join(savedPaths, ", ") + ")" } if len(filePaths) > 0 { textPart += "\n\n(Files saved locally, please read them: " + strings.Join(filePaths, ", ") + ")" } 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 isClaudeEditTool(toolName string) bool { switch toolName { case "Edit", "Write", "NotebookEdit", "MultiEdit": return true default: return false } } func (cs *claudeSession) setPermissionMode(mode string) { cs.permissionMode.Store(mode) cs.autoApprove.Store(mode == "bypassPermissions") cs.acceptEditsOnly.Store(mode == "acceptEdits") cs.dontAsk.Store(mode == "dontAsk") } func (cs *claudeSession) SetLiveMode(mode string) bool { current, _ := cs.permissionMode.Load().(string) if mode == "auto" || mode == "plan" || current == "auto" || current == "plan" { return false } cs.setPermissionMode(mode) return true } 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 { // Phase 1: Close stdin to signal EOF. Claude Code exits cleanly on // stdin close, running Stop hooks (e.g. claude-mem session summary). cs.stdinMu.Lock() if err := cs.stdin.Close(); err != nil { slog.Warn("claudeSession: close stdin", "error", err) } cs.stdinMu.Unlock() graceful := cs.gracefulStopTimeout if graceful <= 0 { graceful = 8 * time.Second // legacy fallback } select { case <-cs.done: slog.Info("claudeSession: exited cleanly after stdin close") return nil case <-time.After(graceful): slog.Warn("claudeSession: graceful stop timed out, sending SIGTERM", "timeout", graceful) } // Phase 2: SIGTERM the whole process group — gives the process and its // descendants (e.g. MCP server bridges) a second chance to run cleanup // handlers that respond to signals but not stdin EOF. if err := signalProcessGroup(cs.cmd, syscall.SIGTERM); err != nil { slog.Warn("claudeSession: signal SIGTERM", "error", err) } select { case <-cs.done: slog.Info("claudeSession: exited after SIGTERM") return nil case <-time.After(5 * time.Second): slog.Warn("claudeSession: SIGTERM timed out, sending SIGKILL") } // Phase 3: SIGKILL the whole process group — last resort. Using a // group-wide kill ensures grandchildren (Claude Code's MCP servers // such as the Telegram bridge) are reaped along with the direct child; // otherwise they can survive as orphans and spin at 100% CPU. cs.cancel() if err := forceKillCmd(cs.cmd); err != nil { slog.Warn("claudeSession: force kill", "error", err) } <-cs.done return nil } // shellJoinArgs joins args into a single string, quoting any arg that // contains whitespace so that a shell-style splitter (like my_cli's // splitCommandLine) preserves each arg as one token. // // Uses single quotes because some splitters (e.g. my_cli) don't support // backslash escapes inside double quotes. For values containing single // quotes, we close the single-quoted segment, add an escaped single // quote, and reopen: 'it'\”s' → it's func shellJoinArgs(args []string) string { var b strings.Builder for i, a := range args { if i > 0 { b.WriteByte(' ') } if !strings.ContainsAny(a, " \t\n\r'\"\\") { b.WriteString(a) continue } b.WriteByte('\'') for _, c := range a { if c == '\'' { b.WriteString("'\\''") } else { b.WriteRune(c) } } b.WriteByte('\'') } return b.String() } // 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 }