This repository has been archived on 2026-05-07. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
ccrm/daemon/internal/claude/process.go

157 lines
3.1 KiB
Go

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 <id>
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)
}