402 lines
8.9 KiB
Go
402 lines
8.9 KiB
Go
package lingmaipc
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
winio "github.com/Microsoft/go-winio"
|
|
)
|
|
|
|
const (
|
|
PipeDir = `\\.\pipe\`
|
|
PipePrefix = "lingma-"
|
|
|
|
MetaRequestID = "ai-coding/request-id"
|
|
MetaMode = "ai-coding/mode"
|
|
MetaModel = "ai-coding/model"
|
|
MetaShellType = "ai-coding/shell-type"
|
|
MetaCurrentFilePath = "ai-coding/current-file-path"
|
|
MetaEnabledMCP = "ai-coding/enabled-mcp-servers"
|
|
)
|
|
|
|
type MetaOptions struct {
|
|
RequestID string
|
|
Mode string
|
|
Model string
|
|
ShellType string
|
|
CurrentFilePath string
|
|
EnabledMCP []any
|
|
}
|
|
|
|
type Notification struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
Method string `json:"method"`
|
|
Params map[string]any `json:"params,omitempty"`
|
|
}
|
|
|
|
type rpcError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
}
|
|
|
|
type responseEnvelope struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID *int `json:"id,omitempty"`
|
|
Method string `json:"method,omitempty"`
|
|
Params map[string]any `json:"params,omitempty"`
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
Error *rpcError `json:"error,omitempty"`
|
|
}
|
|
|
|
type Client struct {
|
|
conn net.Conn
|
|
reader *bufio.Reader
|
|
writeMu sync.Mutex
|
|
pendingMu sync.Mutex
|
|
pending map[int]chan responseEnvelope
|
|
subsMu sync.RWMutex
|
|
subs map[int]chan Notification
|
|
nextID atomic.Int64
|
|
nextSubID atomic.Int64
|
|
closeOnce sync.Once
|
|
closed chan struct{}
|
|
closeErr atomic.Value
|
|
}
|
|
|
|
func ResolvePipePath(explicit string) (string, error) {
|
|
if runtime.GOOS != "windows" {
|
|
return "", errors.New("Lingma IPC proxy currently requires Windows")
|
|
}
|
|
|
|
if pipe := strings.TrimSpace(explicit); pipe != "" {
|
|
return normalizePipePath(pipe), nil
|
|
}
|
|
if pipe := strings.TrimSpace(os.Getenv("LINGMA_IPC_PIPE")); pipe != "" {
|
|
return normalizePipePath(pipe), nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(PipeDir)
|
|
if err != nil {
|
|
return "", fmt.Errorf("enumerate Lingma named pipes: %w", err)
|
|
}
|
|
|
|
names := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if strings.HasPrefix(name, PipePrefix) {
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
if len(names) == 0 {
|
|
return "", errors.New("no active Lingma named pipe was found")
|
|
}
|
|
return PipeDir + names[len(names)-1], nil
|
|
}
|
|
|
|
func normalizePipePath(pipe string) string {
|
|
if strings.HasPrefix(pipe, PipeDir) {
|
|
return pipe
|
|
}
|
|
return PipeDir + pipe
|
|
}
|
|
|
|
func DefaultShellType() string {
|
|
if shellType := strings.TrimSpace(os.Getenv("LINGMA_PROXY_SHELL_TYPE")); shellType != "" {
|
|
return shellType
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
return "powershell"
|
|
}
|
|
if shell := strings.TrimSpace(os.Getenv("SHELL")); shell != "" {
|
|
parts := strings.FieldsFunc(shell, func(r rune) bool { return r == '/' || r == '\\' })
|
|
if len(parts) > 0 {
|
|
return parts[len(parts)-1]
|
|
}
|
|
}
|
|
return "sh"
|
|
}
|
|
|
|
func CreateRequestID(prefix string) string {
|
|
if prefix == "" {
|
|
prefix = "ipc"
|
|
}
|
|
token := make([]byte, 4)
|
|
if _, err := rand.Read(token); err != nil {
|
|
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
|
}
|
|
return fmt.Sprintf("%s-%d-%s", prefix, time.Now().UnixMilli(), hex.EncodeToString(token))
|
|
}
|
|
|
|
func CreateMeta(opts MetaOptions) map[string]any {
|
|
meta := map[string]any{
|
|
MetaRequestID: valueOr(opts.RequestID, CreateRequestID("ipc")),
|
|
MetaShellType: valueOr(opts.ShellType, DefaultShellType()),
|
|
MetaEnabledMCP: emptySliceIfNil(opts.EnabledMCP),
|
|
}
|
|
if strings.TrimSpace(opts.Mode) != "" {
|
|
meta[MetaMode] = strings.TrimSpace(opts.Mode)
|
|
}
|
|
if strings.TrimSpace(opts.Model) != "" {
|
|
meta[MetaModel] = strings.TrimSpace(opts.Model)
|
|
}
|
|
if strings.TrimSpace(opts.CurrentFilePath) != "" {
|
|
meta[MetaCurrentFilePath] = strings.TrimSpace(opts.CurrentFilePath)
|
|
}
|
|
return meta
|
|
}
|
|
|
|
func Connect(ctx context.Context, pipePath string) (*Client, error) {
|
|
if runtime.GOOS != "windows" {
|
|
return nil, errors.New("Lingma IPC proxy currently requires Windows")
|
|
}
|
|
|
|
conn, err := winio.DialPipeContext(ctx, pipePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("connect Lingma IPC pipe %s: %w", pipePath, err)
|
|
}
|
|
|
|
client := &Client{
|
|
conn: conn,
|
|
reader: bufio.NewReader(conn),
|
|
pending: make(map[int]chan responseEnvelope),
|
|
subs: make(map[int]chan Notification),
|
|
closed: make(chan struct{}),
|
|
}
|
|
go client.readLoop()
|
|
return client, nil
|
|
}
|
|
|
|
func (c *Client) Request(ctx context.Context, method string, params any, out any) error {
|
|
if params == nil {
|
|
params = map[string]any{}
|
|
}
|
|
|
|
id := int(c.nextID.Add(1))
|
|
payload := map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal request %s: %w", method, err)
|
|
}
|
|
|
|
responseCh := make(chan responseEnvelope, 1)
|
|
c.pendingMu.Lock()
|
|
c.pending[id] = responseCh
|
|
c.pendingMu.Unlock()
|
|
|
|
if err := c.writeFrame(body); err != nil {
|
|
c.pendingMu.Lock()
|
|
delete(c.pending, id)
|
|
c.pendingMu.Unlock()
|
|
return err
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
c.pendingMu.Lock()
|
|
delete(c.pending, id)
|
|
c.pendingMu.Unlock()
|
|
return ctx.Err()
|
|
case <-c.closed:
|
|
return c.closeError()
|
|
case resp := <-responseCh:
|
|
if resp.Error != nil {
|
|
return fmt.Errorf("Lingma IPC %s failed: %s", method, resp.Error.Message)
|
|
}
|
|
if out == nil || len(resp.Result) == 0 || string(resp.Result) == "null" {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(resp.Result, out); err != nil {
|
|
return fmt.Errorf("decode %s result: %w", method, err)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (c *Client) Subscribe() (<-chan Notification, func()) {
|
|
id := int(c.nextSubID.Add(1))
|
|
ch := make(chan Notification, 2048)
|
|
c.subsMu.Lock()
|
|
c.subs[id] = ch
|
|
c.subsMu.Unlock()
|
|
|
|
cancel := func() {
|
|
c.subsMu.Lock()
|
|
if sub, ok := c.subs[id]; ok {
|
|
delete(c.subs, id)
|
|
close(sub)
|
|
}
|
|
c.subsMu.Unlock()
|
|
}
|
|
return ch, cancel
|
|
}
|
|
|
|
func (c *Client) Close() error {
|
|
c.closeOnce.Do(func() {
|
|
close(c.closed)
|
|
if err := c.conn.Close(); err != nil {
|
|
c.closeErr.Store(err)
|
|
}
|
|
c.failPending(io.EOF)
|
|
c.closeAllSubs()
|
|
})
|
|
if v := c.closeErr.Load(); v != nil {
|
|
return v.(error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) writeFrame(body []byte) error {
|
|
c.writeMu.Lock()
|
|
defer c.writeMu.Unlock()
|
|
|
|
frame := []byte(fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body)))
|
|
if _, err := c.conn.Write(frame); err != nil {
|
|
return fmt.Errorf("write frame header: %w", err)
|
|
}
|
|
if _, err := c.conn.Write(body); err != nil {
|
|
return fmt.Errorf("write frame body: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) readLoop() {
|
|
defer c.Close()
|
|
for {
|
|
body, err := c.readFrame()
|
|
if err != nil {
|
|
if !errors.Is(err, net.ErrClosed) && !errors.Is(err, io.EOF) {
|
|
c.closeErr.Store(err)
|
|
}
|
|
return
|
|
}
|
|
|
|
var envelope responseEnvelope
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
c.closeErr.Store(fmt.Errorf("decode IPC frame: %w", err))
|
|
return
|
|
}
|
|
|
|
if envelope.Method != "" && envelope.ID == nil {
|
|
c.broadcast(Notification{JSONRPC: envelope.JSONRPC, Method: envelope.Method, Params: envelope.Params})
|
|
continue
|
|
}
|
|
|
|
if envelope.ID == nil {
|
|
continue
|
|
}
|
|
|
|
c.pendingMu.Lock()
|
|
ch, ok := c.pending[*envelope.ID]
|
|
if ok {
|
|
delete(c.pending, *envelope.ID)
|
|
}
|
|
c.pendingMu.Unlock()
|
|
if ok {
|
|
ch <- envelope
|
|
close(ch)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) readFrame() ([]byte, error) {
|
|
contentLength := -1
|
|
for {
|
|
line, err := c.reader.ReadString('\n')
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if line == "\r\n" {
|
|
break
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(strings.ToLower(line), "content-length:") {
|
|
raw := strings.TrimSpace(line[len("content-length:"):])
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse content length %q: %w", raw, err)
|
|
}
|
|
contentLength = n
|
|
}
|
|
}
|
|
if contentLength < 0 {
|
|
return nil, errors.New("missing Content-Length header")
|
|
}
|
|
|
|
body := make([]byte, contentLength)
|
|
if _, err := io.ReadFull(c.reader, body); err != nil {
|
|
return nil, err
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func (c *Client) broadcast(notification Notification) {
|
|
c.subsMu.RLock()
|
|
defer c.subsMu.RUnlock()
|
|
for _, ch := range c.subs {
|
|
ch <- notification
|
|
}
|
|
}
|
|
|
|
func (c *Client) failPending(err error) {
|
|
c.pendingMu.Lock()
|
|
defer c.pendingMu.Unlock()
|
|
for id, ch := range c.pending {
|
|
delete(c.pending, id)
|
|
ch <- responseEnvelope{Error: &rpcError{Message: err.Error()}}
|
|
close(ch)
|
|
}
|
|
}
|
|
|
|
func (c *Client) closeAllSubs() {
|
|
c.subsMu.Lock()
|
|
defer c.subsMu.Unlock()
|
|
for id, ch := range c.subs {
|
|
delete(c.subs, id)
|
|
close(ch)
|
|
}
|
|
}
|
|
|
|
func (c *Client) closeError() error {
|
|
if v := c.closeErr.Load(); v != nil {
|
|
return v.(error)
|
|
}
|
|
return io.EOF
|
|
}
|
|
|
|
func valueOr(value string, fallback string) string {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func emptySliceIfNil(v []any) []any {
|
|
if v == nil {
|
|
return []any{}
|
|
}
|
|
return v
|
|
}
|