package acp import ( "bufio" "context" "encoding/json" "errors" "io" "strconv" "sync" "sync/atomic" ) // maxMessageBytes caps a single inbound NDJSON line. ACP messages can embed // resource text, so the limit is generous; a line past it is a framing error. const maxMessageBytes = 32 << 20 // 32 MiB // RequestHandler answers an inbound JSON-RPC request. The returned value is // marshaled as the response result. To control the error code, return a // *RPCError; any other error becomes ErrInternal. type RequestHandler func(ctx context.Context, params json.RawMessage) (any, error) // NotificationHandler reacts to an inbound notification. It cannot reply, so it // returns nothing — errors have nowhere to go on the wire (stderr would corrupt // stdout, which is the JSON-RPC channel). type NotificationHandler func(ctx context.Context, params json.RawMessage) // RPCError lets a handler choose the JSON-RPC error code returned to the client. type RPCError struct { Code int Message string } func (e *RPCError) Error() string { return e.Message } // Conn is one NDJSON JSON-RPC 2.0 connection over a reader/writer pair (stdin/ // stdout in production). It dispatches inbound requests and notifications to // registered handlers, and can itself send outbound notifications (session/update) // and requests (session/request_permission), correlating replies by id. // // Writes are serialized by a mutex, so handlers running on separate goroutines // (a long session/prompt alongside a session/cancel) never interleave a line. // It implements notifier, the dependency the dispatch sink takes. type Conn struct { r io.Reader wmu sync.Mutex enc *json.Encoder nextID atomic.Int64 pmu sync.Mutex pending map[int64]chan rpcResult reqH map[string]RequestHandler notH map[string]NotificationHandler wg sync.WaitGroup closeOnce sync.Once closed chan struct{} } // rpcResult is the outcome of an outbound request, delivered to the waiter. type rpcResult struct { result json.RawMessage err error } // rpcError is the JSON-RPC error object on the wire. type rpcError struct { Code int `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data,omitempty"` } // outbound is a JSON-RPC frame we send. omitempty fields select between request, // notification, success response, and error response shapes. type outbound struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id,omitempty"` Method string `json:"method,omitempty"` Params json.RawMessage `json:"params,omitempty"` Result json.RawMessage `json:"result,omitempty"` Error *rpcError `json:"error,omitempty"` } // inbound is a parsed JSON-RPC frame we received. The combination of id/method // presence distinguishes request, notification, and response. type inbound struct { ID json.RawMessage `json:"id"` Method string `json:"method"` Params json.RawMessage `json:"params"` Result json.RawMessage `json:"result"` Error *rpcError `json:"error"` } // NewConn wires a connection over r (inbound) and w (outbound). Register handlers // with Handle / HandleNotify before calling Serve. The encoder disables HTML // escaping so payloads match main's JSON.stringify output byte-for-byte. func NewConn(r io.Reader, w io.Writer) *Conn { enc := json.NewEncoder(w) enc.SetEscapeHTML(false) return &Conn{ r: r, enc: enc, pending: make(map[int64]chan rpcResult), reqH: make(map[string]RequestHandler), notH: make(map[string]NotificationHandler), closed: make(chan struct{}), } } // Handle registers a request handler for method. Not safe to call concurrently // with Serve; wire all handlers up first. func (c *Conn) Handle(method string, h RequestHandler) { c.reqH[method] = h } // HandleNotify registers a notification handler for method. func (c *Conn) HandleNotify(method string, h NotificationHandler) { c.notH[method] = h } // Serve reads inbound frames until the reader ends or ctx is cancelled. Each // inbound request/notification runs on its own goroutine so a long-running prompt // does not block cancellation or permission replies. When the read loop ends it // cancels in-flight handlers (so prompts abort) and waits for them to return — // flushing fast responses and unwinding aborted ones — before failing any // outstanding outbound requests. Returns nil on clean EOF. func (c *Conn) Serve(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) defer cancel() br := bufio.NewReaderSize(c.r, 64<<10) var loopErr error for { line, err := readLine(br) if len(line) > 0 { c.dispatch(ctx, line) } if err != nil { if !errors.Is(err, io.EOF) { loopErr = err } break } if err := ctx.Err(); err != nil { loopErr = err break } } cancel() // abort in-flight handlers (prompts unwind via ctx) c.wg.Wait() // let them flush their responses before we tear down c.shutdown() // fail any still-pending outbound requests return loopErr } // readLine reads one NDJSON line (without the trailing newline), enforcing the // size cap. It returns the line and any read error; on EOF it still returns the // trailing partial line so a final newline-less frame is processed. func readLine(br *bufio.Reader) ([]byte, error) { var buf []byte for { chunk, err := br.ReadSlice('\n') buf = append(buf, chunk...) if len(buf) > maxMessageBytes { return nil, errors.New("acp: message exceeds size limit") } if err == bufio.ErrBufferFull { continue } // Trim the trailing newline (and CR) if present. n := len(buf) for n > 0 && (buf[n-1] == '\n' || buf[n-1] == '\r') { n-- } return trimSpace(buf[:n]), err } } // trimSpace drops leading/trailing ASCII whitespace without allocating. func trimSpace(b []byte) []byte { i, j := 0, len(b) for i < j && isSpace(b[i]) { i++ } for j > i && isSpace(b[j-1]) { j-- } return b[i:j] } func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' } // dispatch parses one frame and routes it. Requests and notifications fan out to // goroutines; responses resolve inline (they are cheap and need ordering only // against the pending map, which is mutex-guarded). func (c *Conn) dispatch(ctx context.Context, line []byte) { var in inbound if err := json.Unmarshal(line, &in); err != nil { c.writeError(json.RawMessage("null"), ErrParse, "parse error") return } hasID := len(in.ID) > 0 switch { case in.Method != "" && hasID: c.wg.Add(1) go func() { defer c.wg.Done() c.serveRequest(ctx, in.ID, in.Method, in.Params) }() case in.Method != "" && !hasID: if h := c.notH[in.Method]; h != nil { c.wg.Add(1) go func() { defer c.wg.Done() h(ctx, in.Params) }() } case in.Method == "" && hasID: c.resolve(in) default: c.writeError(json.RawMessage("null"), ErrInvalidRequest, "invalid request") } } // serveRequest runs a request handler and writes its response (or error). func (c *Conn) serveRequest(ctx context.Context, id json.RawMessage, method string, params json.RawMessage) { h := c.reqH[method] if h == nil { c.writeError(id, ErrMethodNotFound, "method not found: "+method) return } result, err := h(ctx, params) if err != nil { code := ErrInternal var re *RPCError if errors.As(err, &re) { code = re.Code } c.writeError(id, code, err.Error()) return } raw, err := json.Marshal(result) if err != nil { c.writeError(id, ErrInternal, "marshal result: "+err.Error()) return } _ = c.write(outbound{JSONRPC: "2.0", ID: id, Result: raw}) } // resolve delivers a response to the goroutine waiting on its outbound request. func (c *Conn) resolve(in inbound) { id, err := strconv.ParseInt(string(in.ID), 10, 64) if err != nil { return // we only issue integer ids; an unparsable id isn't ours } c.pmu.Lock() ch := c.pending[id] delete(c.pending, id) c.pmu.Unlock() if ch == nil { return } if in.Error != nil { ch <- rpcResult{err: errors.New(in.Error.Message)} return } ch <- rpcResult{result: in.Result} } // Notify sends a fire-and-forget notification. Satisfies notifier. func (c *Conn) Notify(method string, params any) error { raw, err := json.Marshal(params) if err != nil { return err } return c.write(outbound{JSONRPC: "2.0", Method: method, Params: raw}) } // Request sends an outbound request and blocks until the peer responds, ctx is // cancelled, or the connection closes. Satisfies notifier. func (c *Conn) Request(ctx context.Context, method string, params any) (json.RawMessage, error) { raw, err := json.Marshal(params) if err != nil { return nil, err } id := c.nextID.Add(1) ch := make(chan rpcResult, 1) c.pmu.Lock() c.pending[id] = ch c.pmu.Unlock() defer func() { c.pmu.Lock() delete(c.pending, id) c.pmu.Unlock() }() idRaw, _ := json.Marshal(id) if err := c.write(outbound{JSONRPC: "2.0", ID: idRaw, Method: method, Params: raw}); err != nil { return nil, err } select { case res := <-ch: return res.result, res.err case <-ctx.Done(): return nil, ctx.Err() case <-c.closed: return nil, errors.New("acp: connection closed") } } func (c *Conn) write(m outbound) error { c.wmu.Lock() defer c.wmu.Unlock() return c.enc.Encode(m) } func (c *Conn) writeError(id json.RawMessage, code int, msg string) { _ = c.write(outbound{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}) } // shutdown fails every in-flight outbound request so their goroutines unblock. func (c *Conn) shutdown() { c.closeOnce.Do(func() { close(c.closed) c.pmu.Lock() for id, ch := range c.pending { ch <- rpcResult{err: errors.New("acp: connection closed")} delete(c.pending, id) } c.pmu.Unlock() }) }