157 lines
3.1 KiB
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)
|
|
}
|