Files
cc-connect/agent/acp/list_sessions.go
2026-06-02 23:14:41 +08:00

267 lines
8.1 KiB
Go

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] + "…"
}