Adds a minimal but real ACP stack for the opencode process: - pkg/config: process.args default ["acp"] (opencodeCommand still "opencode") - internal/process: NewManager(command, args) — exec.Command uses args - internal/acp (new): NDJSON transport + JSON-RPC client over the existing process stdio. Implements initialize / session/new / session/prompt / session/cancel. Serves fs/read_text_file and fs/write_text_file from the workspace's fs.FileSystem. terminal/* requests get MethodNotFound. - internal/service/acp_service: per-workspace Client + mutex; starts the process on first prompt; transparently re-init on restart. - internal/api/acp_handler: GET /acp/status, GET /acp/history, POST /acp/prompt, POST /acp/cancel. - internal/model/acp: API DTOs. - internal/acp/client_test: NDJSON split-lines, request/response correlation, notification dispatch, agent-initiated request handling (fs + terminal). Existing process WS endpoint and Shell subsystem are unchanged. Conversation: 019f354c-a51b-7ec3-83ad-c647e9b50b19
199 lines
4.8 KiB
Go
199 lines
4.8 KiB
Go
package acp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"codespace/internal/process"
|
|
)
|
|
|
|
// transport reads NDJSON frames from a process subscription and writes NDJSON
|
|
// frames to the process stdin.
|
|
type transport struct {
|
|
stdin io.WriteCloser
|
|
sub process.Subscription
|
|
frames chan *jsonRPCMessage
|
|
pending map[int]chan *jsonRPCMessage
|
|
pendingMu sync.Mutex
|
|
nextID atomic.Int64
|
|
onRequest func(method string, params json.RawMessage, id int)
|
|
onNotification func(method string, params json.RawMessage)
|
|
onClose func()
|
|
lg *slog.Logger
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
// newTransport creates a transport bound to the given stdin writer and output
|
|
// subscription. Call start to begin the read/dispatch goroutines.
|
|
func newTransport(stdin io.WriteCloser, sub process.Subscription, lg *slog.Logger) *transport {
|
|
if lg == nil {
|
|
lg = slog.Default()
|
|
}
|
|
return &transport{
|
|
stdin: stdin,
|
|
sub: sub,
|
|
frames: make(chan *jsonRPCMessage, 64),
|
|
pending: make(map[int]chan *jsonRPCMessage),
|
|
lg: lg,
|
|
}
|
|
}
|
|
|
|
// start launches the read and dispatch loops.
|
|
func (t *transport) start() {
|
|
go t.readLoop()
|
|
go t.dispatchLoop()
|
|
}
|
|
|
|
// readLoop consumes raw bytes from the subscription, splits on newlines, and
|
|
// feeds parsed JSON-RPC frames to the frames channel.
|
|
func (t *transport) readLoop() {
|
|
defer close(t.frames)
|
|
buf := bytes.NewBuffer(make([]byte, 0, 4096))
|
|
for chunk := range t.sub.Output() {
|
|
buf.Write(chunk)
|
|
for {
|
|
data := buf.Bytes()
|
|
idx := bytes.IndexByte(data, '\n')
|
|
if idx < 0 {
|
|
break
|
|
}
|
|
line := make([]byte, idx)
|
|
copy(line, data[:idx])
|
|
buf.Next(idx + 1)
|
|
msg := &jsonRPCMessage{}
|
|
if err := json.Unmarshal(line, msg); err != nil {
|
|
t.lg.Error("acp invalid ndjson line", "error", err)
|
|
continue
|
|
}
|
|
t.frames <- msg
|
|
}
|
|
}
|
|
}
|
|
|
|
// dispatchLoop routes frames to pending request channels, notification
|
|
// handlers, or incoming-request handlers.
|
|
func (t *transport) dispatchLoop() {
|
|
defer func() {
|
|
if t.onClose != nil {
|
|
t.onClose()
|
|
}
|
|
}()
|
|
for msg := range t.frames {
|
|
switch {
|
|
case msg.ID != nil && msg.Method != "":
|
|
// Incoming request from the agent.
|
|
if t.onRequest != nil {
|
|
id := *msg.ID
|
|
go t.onRequest(msg.Method, msg.Params, id)
|
|
}
|
|
case msg.ID == nil && msg.Method != "":
|
|
// Notification from the agent.
|
|
if t.onNotification != nil {
|
|
go t.onNotification(msg.Method, msg.Params)
|
|
}
|
|
case msg.ID != nil && (msg.Result != nil || msg.Error != nil):
|
|
// Response to a client-initiated request.
|
|
id := *msg.ID
|
|
t.pendingMu.Lock()
|
|
ch, ok := t.pending[id]
|
|
delete(t.pending, id)
|
|
t.pendingMu.Unlock()
|
|
if ok {
|
|
select {
|
|
case ch <- msg:
|
|
default:
|
|
}
|
|
} else {
|
|
t.lg.Debug("acp unsolicited response", "id", id)
|
|
}
|
|
default:
|
|
t.lg.Debug("acp unhandled frame", "method", msg.Method, "id", msg.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// call sends a JSON-RPC request and waits for its response.
|
|
func (t *transport) call(ctx context.Context, method string, params, result any) error {
|
|
id := int(t.nextID.Add(1))
|
|
rawParams, err := json.Marshal(params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
msg := &jsonRPCMessage{JSONRPC: "2.0", ID: &id, Method: method, Params: rawParams}
|
|
ch := make(chan *jsonRPCMessage, 1)
|
|
t.pendingMu.Lock()
|
|
t.pending[id] = ch
|
|
t.pendingMu.Unlock()
|
|
defer func() {
|
|
t.pendingMu.Lock()
|
|
delete(t.pending, id)
|
|
t.pendingMu.Unlock()
|
|
}()
|
|
if err := t.write(msg); err != nil {
|
|
return err
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case resp := <-ch:
|
|
if resp.Error != nil {
|
|
return errors.New(resp.Error.Message)
|
|
}
|
|
if result != nil && resp.Result != nil {
|
|
return json.Unmarshal(resp.Result, result)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// sendNotification writes a JSON-RPC notification (no id).
|
|
func (t *transport) sendNotification(method string, params any) error {
|
|
rawParams, err := json.Marshal(params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
msg := &jsonRPCMessage{JSONRPC: "2.0", Method: method, Params: rawParams}
|
|
return t.write(msg)
|
|
}
|
|
|
|
// sendResponse writes a JSON-RPC response for an agent-initiated request.
|
|
func (t *transport) sendResponse(id int, result any, rpcErr *rpcError) error {
|
|
msg := &jsonRPCMessage{JSONRPC: "2.0", ID: &id}
|
|
if rpcErr != nil {
|
|
msg.Error = rpcErr
|
|
} else {
|
|
raw, err := json.Marshal(result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
msg.Result = raw
|
|
}
|
|
return t.write(msg)
|
|
}
|
|
|
|
// write marshals a JSON-RPC message and appends a newline.
|
|
func (t *transport) write(msg *jsonRPCMessage) error {
|
|
data, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
_, err = t.stdin.Write(data)
|
|
return err
|
|
}
|
|
|
|
// Close shuts down the transport by closing the subscription. This causes the
|
|
// read loop to exit and the dispatch loop to follow.
|
|
func (t *transport) Close() error {
|
|
t.closeOnce.Do(func() {
|
|
_ = t.sub.Close()
|
|
})
|
|
return nil
|
|
}
|