package acp import ( "bytes" "io" "os" "sync" "time" ) // TraceEnv names the file every message to and from an agent is appended to, // when it is set. It exists for one question — "what did the agent actually // send?" — which nothing on the screen can answer: an update this client // cannot read is counted, not shown, and an agent that never sent one looks // exactly like an agent whose message was dropped. // // TURBO_ACP_TRACE=/tmp/acp.log turbo-go // // The trace is never allowed to break the editor: a file that cannot be // opened or written means no trace, and nothing else. const TraceEnv = "TURBO_ACP_TRACE" // traceStream wraps a stream so that each line read from and written to it is // also appended to the trace file, when TraceEnv names one. func traceStream(stream io.ReadWriteCloser) io.ReadWriteCloser { path := os.Getenv(TraceEnv) if path == "" { return stream } file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) if err != nil { return stream } return &tracer{stream: stream, file: file, now: time.Now} } // tracer is the wrapping stream. Each direction keeps its own partial line, // because a read may end anywhere and the protocol's unit is the line. type tracer struct { stream io.ReadWriteCloser file *os.File now func() time.Time mu sync.Mutex partial [2]bytes.Buffer // 0 read, 1 written } // The two directions, as the trace marks them. const ( fromAgent = 0 toAgent = 1 ) var directionMarks = [2]string{"<- ", "-> "} // Read reads from the agent, and traces the lines that completes. func (t *tracer) Read(p []byte) (int, error) { n, err := t.stream.Read(p) if n > 0 { t.trace(fromAgent, p[:n]) } return n, err } // Write writes to the agent, and traces the lines that completes. func (t *tracer) Write(p []byte) (int, error) { n, err := t.stream.Write(p) if n > 0 { t.trace(toAgent, p[:n]) } return n, err } // Close closes the stream, flushes whatever partial line either direction // still held, and closes the file. func (t *tracer) Close() error { err := t.stream.Close() t.mu.Lock() defer t.mu.Unlock() for direction := range t.partial { if t.partial[direction].Len() > 0 { t.emit(direction, t.partial[direction].Bytes()) t.partial[direction].Reset() } } _ = t.file.Close() return err } // trace appends data to the direction's partial line and emits every complete // line it now holds. func (t *tracer) trace(direction int, data []byte) { t.mu.Lock() defer t.mu.Unlock() buffer := &t.partial[direction] buffer.Write(data) for { line, rest, found := bytes.Cut(buffer.Bytes(), []byte{'\n'}) if !found { return } t.emit(direction, line) remaining := append([]byte{}, rest...) buffer.Reset() buffer.Write(remaining) } } // emit writes one traced line: the time, the direction, the message. func (t *tracer) emit(direction int, line []byte) { stamp := t.now().Format("15:04:05.000") _, _ = t.file.WriteString(stamp + " " + directionMarks[direction] + string(line) + "\n") } // TraceStreamForTest wraps a stream the way Start wraps an agent's pipes, so // that the trace can be tested over an in-process pipe. It reads TraceEnv // like the real thing, and returns the stream untouched when it is unset. func TraceStreamForTest(stream io.ReadWriteCloser) io.ReadWriteCloser { return traceStream(stream) }