初始化仓库
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// listSessionsProbeTimeout bounds how long we wait for a one-shot
|
||||
// `session/list` round-trip before giving up. Keep this short — the
|
||||
// whole point of the probe is that it's quick; if the ACP agent is
|
||||
// slow we'd rather return nothing than block `/ls` in IM.
|
||||
var listSessionsProbeTimeout = 15 * time.Second
|
||||
|
||||
// acpModeInfo mirrors the ACP `modes.availableModes[]` shape sent by
|
||||
// servers like `devin acp`, Cursor Agent, Copilot CLI, etc.
|
||||
type acpModeInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// acpModesBlock mirrors the `modes` object returned inside `session/new`
|
||||
// and `session/load` responses.
|
||||
type acpModesBlock struct {
|
||||
CurrentModeID string `json:"currentModeId"`
|
||||
AvailableModes []acpModeInfo `json:"availableModes"`
|
||||
}
|
||||
|
||||
// acpInitializeResult is the subset of `initialize` fields this package
|
||||
// cares about. Additional vendor metadata is ignored.
|
||||
type acpInitializeResult struct {
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
AgentCapabilities struct {
|
||||
LoadSession bool `json:"loadSession"`
|
||||
SessionCapabilities struct {
|
||||
// ACP advertises capabilities as objects (possibly empty);
|
||||
// treat "field present" as "supported" regardless of contents.
|
||||
List json.RawMessage `json:"list,omitempty"`
|
||||
} `json:"sessionCapabilities"`
|
||||
} `json:"agentCapabilities"`
|
||||
}
|
||||
|
||||
// acpSessionListResult mirrors a `session/list` response.
|
||||
type acpSessionListResult struct {
|
||||
Sessions []acpSessionListEntry `json:"sessions"`
|
||||
}
|
||||
|
||||
type acpSessionListEntry struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Cwd string `json:"cwd"`
|
||||
Title string `json:"title,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// probeSpawn launches `<cmd> <args...>`, sets up a JSON-RPC transport
|
||||
// and starts its readLoop. The caller owns the returned `teardown`
|
||||
// func and must invoke it to reap the child process.
|
||||
func (a *Agent) probeSpawn(ctx context.Context, cwd string) (*transport, *bytes.Buffer, func(), error) {
|
||||
cmd := exec.CommandContext(ctx, a.command, a.args...)
|
||||
cmd.Dir = cwd
|
||||
cmd.Env = core.MergeEnv(os.Environ(), a.extraEnv)
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("acp: probe stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("acp: probe stdout pipe: %w", err)
|
||||
}
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = io.MultiWriter(&stderrBuf)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("acp: probe start %s: %w", a.command, err)
|
||||
}
|
||||
|
||||
// The server-request handler needs to reference `tr` itself in order
|
||||
// to respondError; declare via var so the closure captures the
|
||||
// variable (which is assigned to a *transport below) rather than an
|
||||
// uninitialised copy.
|
||||
var tr *transport
|
||||
tr = newTransport(stdout, stdin,
|
||||
func(method string, _ json.RawMessage) {
|
||||
slog.Debug("acp-probe: notification", "method", method)
|
||||
},
|
||||
func(_ string, id json.RawMessage, _ json.RawMessage) {
|
||||
_ = tr.respondError(id, -32601, "probe: method not implemented")
|
||||
},
|
||||
)
|
||||
|
||||
readCtx, cancelRead := context.WithCancel(ctx)
|
||||
go tr.readLoop(readCtx)
|
||||
|
||||
teardown := func() {
|
||||
cancelRead()
|
||||
_ = stdin.Close()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
<-done
|
||||
}
|
||||
}
|
||||
return tr, &stderrBuf, teardown, nil
|
||||
}
|
||||
|
||||
// probeInitialize performs the ACP handshake on an already-spawned
|
||||
// transport and returns the parsed initialize result.
|
||||
func probeInitialize(ctx context.Context, tr *transport) (*acpInitializeResult, error) {
|
||||
raw, err := tr.call(ctx, "initialize", map[string]any{
|
||||
"protocolVersion": 1,
|
||||
"clientCapabilities": map[string]any{
|
||||
"fs": map[string]any{"readTextFile": false, "writeTextFile": false},
|
||||
"terminal": false,
|
||||
},
|
||||
"clientInfo": map[string]any{"name": "cc-connect", "version": "1.0.0"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acp: probe initialize: %w", err)
|
||||
}
|
||||
var res acpInitializeResult
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return nil, fmt.Errorf("acp: probe parse initialize: %w", err)
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
// probeListSessions runs `session/list` on the given transport.
|
||||
// Returns (nil, nil) if the agent refuses the call with
|
||||
// method-not-found / invalid-request — callers interpret that as
|
||||
// "unsupported" rather than "real error".
|
||||
func probeListSessions(ctx context.Context, tr *transport, cwdFilter string) ([]acpSessionListEntry, error) {
|
||||
params := map[string]any{}
|
||||
if cwdFilter != "" {
|
||||
params["cwd"] = cwdFilter
|
||||
}
|
||||
raw, err := tr.call(ctx, "session/list", params)
|
||||
if err != nil {
|
||||
if rpcErr, ok := err.(*rpcErrPayload); ok {
|
||||
if rpcErr.Code == -32601 || rpcErr.Code == -32600 {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var out acpSessionListResult
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("acp: parse session/list: %w", err)
|
||||
}
|
||||
return out.Sessions, nil
|
||||
}
|
||||
|
||||
// ListSessions returns past sessions reported by the ACP agent, scoped
|
||||
// to the agent's work_dir. If the agent does not advertise
|
||||
// sessionCapabilities.list or the call soft-fails, returns nil.
|
||||
//
|
||||
// This runs a one-shot `<command>` process that performs only
|
||||
// initialize + session/list, so it does NOT allocate a real session on
|
||||
// the backend (unlike session/new). Cost is roughly a single ACP
|
||||
// handshake round-trip (~100-500ms for Devin).
|
||||
func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) {
|
||||
if a.listUnsupported.Load() {
|
||||
// Already learned this agent doesn't support session/list;
|
||||
// fast-path out to avoid respawning just to rediscover that.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
a.mu.RLock()
|
||||
workDir := a.workDir
|
||||
a.mu.RUnlock()
|
||||
absWorkDir, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
absWorkDir = workDir
|
||||
}
|
||||
|
||||
probeCtx, cancel := context.WithTimeout(ctx, listSessionsProbeTimeout)
|
||||
defer cancel()
|
||||
|
||||
started := time.Now()
|
||||
tr, stderrBuf, teardown, err := a.probeSpawn(probeCtx, absWorkDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer teardown()
|
||||
|
||||
init, err := probeInitialize(probeCtx, tr)
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(stderrBuf.String()); msg != "" {
|
||||
return nil, fmt.Errorf("%w (stderr: %s)", err, truncateForLog(msg, 200))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(init.AgentCapabilities.SessionCapabilities.List) == 0 {
|
||||
slog.Info("acp: session/list unsupported by agent, caching for fast-path", "command", a.command)
|
||||
a.listUnsupported.Store(true)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
entries, err := probeListSessions(probeCtx, tr, absWorkDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entries == nil {
|
||||
a.listUnsupported.Store(true)
|
||||
return nil, nil
|
||||
}
|
||||
out := convertSessionList(entries, absWorkDir)
|
||||
slog.Info("acp: session/list completed",
|
||||
"total_entries", len(entries),
|
||||
"matching_cwd", len(out),
|
||||
"cwd", absWorkDir,
|
||||
"elapsed", time.Since(started),
|
||||
)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// convertSessionList maps ACP session/list entries to core.AgentSessionInfo.
|
||||
// If `cwdFilter` is non-empty, entries whose cwd does not match are dropped;
|
||||
// ACP servers SHOULD filter themselves when the request includes cwd, but
|
||||
// we defend against servers that ignore the hint (see probe_caps.py output
|
||||
// against devin acp: filter is respected there, but we still double-check).
|
||||
func convertSessionList(entries []acpSessionListEntry, cwdFilter string) []core.AgentSessionInfo {
|
||||
out := make([]core.AgentSessionInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if cwdFilter != "" && e.Cwd != "" && !strings.EqualFold(filepath.Clean(e.Cwd), filepath.Clean(cwdFilter)) {
|
||||
continue
|
||||
}
|
||||
info := core.AgentSessionInfo{
|
||||
ID: e.SessionID,
|
||||
Summary: strings.TrimSpace(e.Title),
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, e.UpdatedAt); err == nil {
|
||||
info.ModifiedAt = t
|
||||
} else if t, err := time.Parse(time.RFC3339Nano, e.UpdatedAt); err == nil {
|
||||
info.ModifiedAt = t
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func truncateForLog(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user