package claude import ( "bufio" "context" "encoding/json" "fmt" "io" "os/exec" "sync" ) const maxScannerBuffer = 4 * 1024 * 1024 // 4MB // ProcessConfig configures a Claude subprocess. type ProcessConfig struct { ClaudeBin string WorkDir string ExtraArgs []string ResumeSessionID string // adds --resume Handler MessageHandler } // Process manages a Claude subprocess communicating via JSON lines. type Process struct { cmd *exec.Cmd stdin io.WriteCloser stdinMu sync.Mutex done chan struct{} exitErr error } // NewProcess creates (but does not start) a Claude subprocess. func NewProcess(cfg ProcessConfig) *Process { args := []string{"--print", "--output-format", "stream-json", "--verbose"} if cfg.ResumeSessionID != "" { args = append(args, "--resume", cfg.ResumeSessionID) } args = append(args, cfg.ExtraArgs...) cmd := exec.Command(cfg.ClaudeBin, args...) cmd.Dir = cfg.WorkDir return &Process{ cmd: cmd, done: make(chan struct{}), } } // Start spawns the subprocess and begins reading stdout. func (p *Process) Start(ctx context.Context, handler MessageHandler) error { stdin, err := p.cmd.StdinPipe() if err != nil { return fmt.Errorf("creating stdin pipe: %w", err) } p.stdin = stdin stdout, err := p.cmd.StdoutPipe() if err != nil { return fmt.Errorf("creating stdout pipe: %w", err) } if err := p.cmd.Start(); err != nil { return fmt.Errorf("starting claude process: %w", err) } go p.readLoop(stdout, handler) go p.waitLoop() return nil } // WriteMessage sends a JSON message to Claude's stdin. func (p *Process) WriteMessage(msg any) error { data, err := json.Marshal(msg) if err != nil { return fmt.Errorf("marshaling message: %w", err) } p.stdinMu.Lock() defer p.stdinMu.Unlock() if p.stdin == nil { return fmt.Errorf("process stdin closed") } data = append(data, '\n') _, err = p.stdin.Write(data) return err } // Kill terminates the subprocess. func (p *Process) Kill() error { if p.cmd.Process == nil { return nil } return p.cmd.Process.Kill() } // Alive returns true if the subprocess is still running. func (p *Process) Alive() bool { select { case <-p.done: return false default: return true } } // Done returns a channel that closes when the subprocess exits. func (p *Process) Done() <-chan struct{} { return p.done } // ExitErr returns the exit error, if any. Only valid after Done is closed. func (p *Process) ExitErr() error { return p.exitErr } func (p *Process) readLoop(stdout io.Reader, handler MessageHandler) { scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, maxScannerBuffer), maxScannerBuffer) for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { continue } var msg CLIMessage if err := json.Unmarshal(line, &msg); err != nil { continue } // Store the raw bytes for downstream parsing raw := make([]byte, len(line)) copy(raw, line) msg.Raw = json.RawMessage(raw) if handler != nil { handler(&msg) } } } func (p *Process) waitLoop() { p.exitErr = p.cmd.Wait() p.stdinMu.Lock() if p.stdin != nil { p.stdin.Close() p.stdin = nil } p.stdinMu.Unlock() close(p.done) }