package acp import ( "bufio" "fmt" "io" "os" "os/exec" "path/filepath" "sync" ) // MaxLogLines is how much of an agent's standard error is kept for the status // dialog. It is where a misconfigured model endpoint reports itself, and it is // also where a chatty agent would otherwise grow without bound. const MaxLogLines = 200 // process is a running agent: the child, its pipes as one stream, and whatever // it has said on standard error. type process struct { command *exec.Cmd stream io.ReadWriteCloser mu sync.Mutex log []string over int // lines dropped past the cap } // pipes joins a child's stdin and stdout into the single stream jsonrpc wants. // // Closing it closes the input, which is how an agent is told to finish: every // well-behaved one exits on end of input, so the process ends without a signal. type pipes struct { io.ReadCloser io.WriteCloser } // Close shuts both halves, reporting the first failure. func (p pipes) Close() error { writeErr := p.WriteCloser.Close() readErr := p.ReadCloser.Close() if writeErr != nil { return writeErr } return readErr } // startProcess runs an agent and returns it, with its pipes joined into one // stream and its standard error being drained. // // The child's environment is the editor's with the agent's own added on top, so // a credential already in the environment reaches the agent without being // written into a file. func startProcess(agent Agent, projectDir string, onLog func(string)) (*process, error) { command := exec.Command(agent.Command, agent.Args...) command.Dir = workingDirectory(agent, projectDir) command.Env = environment(agent) stdin, err := command.StdinPipe() if err != nil { return nil, fmt.Errorf("acp: connecting to %s: %w", agent.Command, err) } stdout, err := command.StdoutPipe() if err != nil { return nil, fmt.Errorf("acp: connecting to %s: %w", agent.Command, err) } stderr, err := command.StderrPipe() if err != nil { return nil, fmt.Errorf("acp: connecting to %s: %w", agent.Command, err) } if err := command.Start(); err != nil { return nil, fmt.Errorf("acp: starting %s: %w", agent.CommandLine(), err) } p := &process{command: command, stream: pipes{ReadCloser: stdout, WriteCloser: stdin}} go p.drain(stderr, onLog) return p, nil } // workingDirectory returns where the agent should run: its own cwd if it named // one, and the project otherwise. func workingDirectory(agent Agent, projectDir string) string { if agent.Cwd == "" { return projectDir } if filepath.IsAbs(agent.Cwd) { return agent.Cwd } return filepath.Join(projectDir, agent.Cwd) } // environment returns the child's environment: the editor's, plus the agent's. func environment(agent Agent) []string { if len(agent.Env) == 0 { return nil // nil means "inherit", which is what os/exec does with it } env := os.Environ() for name, value := range agent.Env { env = append(env, name+"="+value) } return env } // drain reads the agent's standard error, keeping the last lines of it. // // It must be read rather than ignored: a full pipe blocks the child, and an // agent that logs enough would stop answering with no error anywhere. It is // also not protocol — `docker agent` prints a welcome banner here — so nothing // read from it ever reaches the JSON-RPC layer. func (p *process) drain(stderr io.Reader, onLog func(string)) { scanner := bufio.NewScanner(stderr) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { line := scanner.Text() p.record(line) if onLog != nil { onLog(line) } } } // record keeps one line of the log, dropping the oldest past the cap. func (p *process) record(line string) { p.mu.Lock() defer p.mu.Unlock() p.log = append(p.log, line) if len(p.log) > MaxLogLines { p.log = p.log[len(p.log)-MaxLogLines:] p.over++ } } // Log returns what the agent has said on standard error, most recent last. func (p *process) Log() []string { p.mu.Lock() defer p.mu.Unlock() out := make([]string, len(p.log)) copy(out, p.log) return out } // Dropped returns how many log lines were lost to the cap. func (p *process) Dropped() int { p.mu.Lock() defer p.mu.Unlock() return p.over } // Stop ends the agent. // // Closing the stream closes its standard input, which is how the protocol says // to finish: an agent reading end of input exits. The process is then killed // anyway, because an agent that ignores it must not outlive the window — and // Wait is what stops it becoming a zombie. func (p *process) Stop() error { err := p.stream.Close() if p.command.Process != nil { _ = p.command.Process.Kill() } _ = p.command.Wait() return err }