初始化仓库
This commit is contained in:
@@ -0,0 +1,762 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// toolInputCacheMaxEntries caps toolInputByID growth; beyond this we evict
|
||||
// roughly half the map (iteration order is arbitrary) to bound memory.
|
||||
const toolInputCacheMaxEntries = 1000
|
||||
|
||||
type acpSession struct {
|
||||
workDir string
|
||||
events chan core.Event
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
alive atomic.Bool
|
||||
|
||||
cmd *exec.Cmd
|
||||
tr *transport
|
||||
|
||||
acpSessMu sync.RWMutex
|
||||
acpSessID string
|
||||
|
||||
sendMu sync.Mutex
|
||||
|
||||
permMu sync.Mutex
|
||||
permByID map[string]permState
|
||||
|
||||
toolInputMu sync.Mutex
|
||||
toolInputByID map[string]string // toolCallId -> summarized tool input
|
||||
|
||||
// modesMu guards availableModes and currentMode. Both fields are
|
||||
// populated on handshake (session/new or session/load response) and
|
||||
// updated whenever SetLiveMode succeeds or the server announces a
|
||||
// mode change via session/update.
|
||||
modesMu sync.RWMutex
|
||||
availableModes []acpModeInfo
|
||||
currentMode string
|
||||
|
||||
callbacks sessionCallbacks // may be nil (tests, integration harness)
|
||||
}
|
||||
|
||||
type permState struct {
|
||||
RPCID json.RawMessage
|
||||
Options []permissionOption
|
||||
}
|
||||
|
||||
// acpSessionConfig bundles the inputs newACPSession needs. It's a
|
||||
// struct rather than a long positional argument list because we keep
|
||||
// adding optional knobs (initialMode, callbacks) and would otherwise
|
||||
// break every call site each time.
|
||||
type acpSessionConfig struct {
|
||||
command string
|
||||
args []string
|
||||
extraEnv []string
|
||||
workDir string
|
||||
resumeSessionID string
|
||||
authMethod string
|
||||
initialMode string // if non-empty, applied via session/set_mode after session/new
|
||||
callbacks sessionCallbacks // may be nil
|
||||
}
|
||||
|
||||
func newACPSession(ctx context.Context, cfg acpSessionConfig) (*acpSession, error) {
|
||||
absWorkDir, err := filepath.Abs(cfg.workDir)
|
||||
if err != nil {
|
||||
absWorkDir = cfg.workDir
|
||||
}
|
||||
|
||||
sessionCtx, cancel := context.WithCancel(ctx)
|
||||
s := &acpSession{
|
||||
workDir: absWorkDir,
|
||||
events: make(chan core.Event, 128),
|
||||
ctx: sessionCtx,
|
||||
cancel: cancel,
|
||||
permByID: make(map[string]permState),
|
||||
toolInputByID: make(map[string]string),
|
||||
acpSessID: cfg.resumeSessionID,
|
||||
callbacks: cfg.callbacks,
|
||||
}
|
||||
s.alive.Store(true)
|
||||
|
||||
cmd := exec.CommandContext(sessionCtx, cfg.command, cfg.args...)
|
||||
cmd.Dir = absWorkDir
|
||||
cmd.Env = core.MergeEnv(os.Environ(), cfg.extraEnv)
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("acp: stdin pipe: %w", err)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("acp: stdout pipe: %w", err)
|
||||
}
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = io.MultiWriter(&stderrBuf, os.Stderr)
|
||||
|
||||
s.cmd = cmd
|
||||
s.tr = newTransport(stdout, stdin, s.onNotification, s.onServerRequest)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("acp: start %s: %w", cfg.command, err)
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
s.tr.readLoop(sessionCtx)
|
||||
waitErr := cmd.Wait()
|
||||
if waitErr != nil {
|
||||
msg := stderrBuf.String()
|
||||
if msg != "" {
|
||||
slog.Error("acp: process exited", "error", waitErr, "stderr", msg)
|
||||
s.emit(core.Event{Type: core.EventError, Error: fmt.Errorf("%s", strings.TrimSpace(msg))})
|
||||
} else {
|
||||
slog.Debug("acp: process exited", "error", waitErr)
|
||||
}
|
||||
}
|
||||
s.alive.Store(false)
|
||||
}()
|
||||
|
||||
if err := s.handshake(cfg.resumeSessionID, cfg.authMethod); err != nil {
|
||||
_ = s.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply the agent-level mode preference now that we have a session
|
||||
// id. If set_mode fails (e.g. modeId unknown to this backend) we
|
||||
// log and carry on with whatever mode the server defaulted to —
|
||||
// the alternative would be to reject the session entirely, which
|
||||
// is worse UX for a non-critical control.
|
||||
if strings.TrimSpace(cfg.initialMode) != "" {
|
||||
if ok := s.SetLiveMode(cfg.initialMode); !ok {
|
||||
slog.Warn("acp: initial mode could not be applied",
|
||||
"mode", cfg.initialMode,
|
||||
"session_id", s.currentACPSessionID(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// handshake runs initialize → optional authenticate → session/load or
|
||||
// session/new, and caches any modes the server advertises so
|
||||
// SetLiveMode / PermissionModes can answer correctly.
|
||||
func (s *acpSession) handshake(resumeSessionID string, authMethod string) error {
|
||||
initParams := 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",
|
||||
},
|
||||
}
|
||||
res, err := s.tr.call(s.ctx, "initialize", initParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acp: initialize: %w", err)
|
||||
}
|
||||
|
||||
var initOut acpInitializeResult
|
||||
if err := json.Unmarshal(res, &initOut); err != nil {
|
||||
return fmt.Errorf("acp: parse initialize result: %w", err)
|
||||
}
|
||||
listSupported := len(initOut.AgentCapabilities.SessionCapabilities.List) > 0
|
||||
slog.Debug("acp: initialized",
|
||||
"protocol", initOut.ProtocolVersion,
|
||||
"load_session", initOut.AgentCapabilities.LoadSession,
|
||||
"list_sessions", listSupported,
|
||||
)
|
||||
if s.callbacks != nil {
|
||||
s.callbacks.reportListSupported(listSupported)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(authMethod) != "" {
|
||||
if _, err := s.tr.call(s.ctx, "authenticate", map[string]any{
|
||||
"methodId": authMethod,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("acp: authenticate (%s): %w", authMethod, err)
|
||||
}
|
||||
slog.Debug("acp: authenticated", "method_id", authMethod)
|
||||
}
|
||||
|
||||
wantResume := resumeSessionID != "" && resumeSessionID != core.ContinueSession
|
||||
if wantResume && initOut.AgentCapabilities.LoadSession {
|
||||
loadParams := map[string]any{
|
||||
"sessionId": resumeSessionID,
|
||||
"cwd": s.workDir,
|
||||
"mcpServers": []any{},
|
||||
}
|
||||
loadRes, err := s.tr.call(s.ctx, "session/load", loadParams)
|
||||
if err != nil {
|
||||
slog.Warn("acp: session/load failed, starting new session", "error", err)
|
||||
} else {
|
||||
var lr struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Modes *acpModesBlock `json:"modes"`
|
||||
}
|
||||
if json.Unmarshal(loadRes, &lr) == nil && lr.SessionID != "" {
|
||||
s.setACPSessionID(lr.SessionID)
|
||||
s.absorbModes(lr.Modes)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newParams := map[string]any{
|
||||
"cwd": s.workDir,
|
||||
"mcpServers": []any{},
|
||||
}
|
||||
newRes, err := s.tr.call(s.ctx, "session/new", newParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acp: session/new: %w", err)
|
||||
}
|
||||
var sn struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Modes *acpModesBlock `json:"modes"`
|
||||
}
|
||||
if err := json.Unmarshal(newRes, &sn); err != nil {
|
||||
return fmt.Errorf("acp: parse session/new: %w", err)
|
||||
}
|
||||
if sn.SessionID == "" {
|
||||
return fmt.Errorf("acp: session/new: empty sessionId")
|
||||
}
|
||||
s.setACPSessionID(sn.SessionID)
|
||||
s.absorbModes(sn.Modes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// absorbModes copies a modes block into the session's cache and fans
|
||||
// it out to the parent agent callbacks (if any). Both the session and
|
||||
// the agent need the information: the session uses it to validate
|
||||
// SetLiveMode inputs; the agent uses it to render `/mode` menus in IM.
|
||||
func (s *acpSession) absorbModes(block *acpModesBlock) {
|
||||
if block == nil || len(block.AvailableModes) == 0 {
|
||||
return
|
||||
}
|
||||
s.modesMu.Lock()
|
||||
s.availableModes = append(s.availableModes[:0], block.AvailableModes...)
|
||||
if block.CurrentModeID != "" {
|
||||
s.currentMode = block.CurrentModeID
|
||||
}
|
||||
s.modesMu.Unlock()
|
||||
if s.callbacks != nil {
|
||||
s.callbacks.reportModes(*block)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *acpSession) setACPSessionID(id string) {
|
||||
s.acpSessMu.Lock()
|
||||
s.acpSessID = id
|
||||
s.acpSessMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *acpSession) currentACPSessionID() string {
|
||||
s.acpSessMu.RLock()
|
||||
defer s.acpSessMu.RUnlock()
|
||||
return s.acpSessID
|
||||
}
|
||||
|
||||
// CurrentMode returns the ACP modeId most recently applied or reported
|
||||
// for this session. Empty when the server never sent a modes block.
|
||||
func (s *acpSession) CurrentMode() string {
|
||||
s.modesMu.RLock()
|
||||
defer s.modesMu.RUnlock()
|
||||
return s.currentMode
|
||||
}
|
||||
|
||||
// SetLiveMode applies a permission mode change to the running session
|
||||
// via `session/set_mode`. Returns true on success, false if the mode
|
||||
// is unknown / the call errors / the session is closed.
|
||||
//
|
||||
// This is the implementation of core.LiveModeSwitcher for ACP
|
||||
// sessions; the engine invokes it when the user runs `/mode <x>`,
|
||||
// `/plan`, `/bypass`, etc. while a session is active.
|
||||
//
|
||||
// Client-side validation is important because at least one ACP server
|
||||
// (devin acp in 2026.4.9) silently accepts unknown modeIds without
|
||||
// any error, so a server-only check would let typos go undetected.
|
||||
func (s *acpSession) SetLiveMode(mode string) bool {
|
||||
if !s.alive.Load() {
|
||||
return false
|
||||
}
|
||||
sid := s.currentACPSessionID()
|
||||
if sid == "" {
|
||||
return false
|
||||
}
|
||||
modeID := s.matchAvailableMode(mode)
|
||||
if modeID == "" {
|
||||
slog.Debug("acp: SetLiveMode rejected unknown mode",
|
||||
"mode", mode,
|
||||
"session_id", sid,
|
||||
)
|
||||
return false
|
||||
}
|
||||
if _, err := s.tr.call(s.ctx, "session/set_mode", map[string]any{
|
||||
"sessionId": sid,
|
||||
"modeId": modeID,
|
||||
}); err != nil {
|
||||
slog.Warn("acp: session/set_mode failed",
|
||||
"mode", modeID,
|
||||
"session_id", sid,
|
||||
"error", err,
|
||||
)
|
||||
return false
|
||||
}
|
||||
s.modesMu.Lock()
|
||||
s.currentMode = modeID
|
||||
s.modesMu.Unlock()
|
||||
if s.callbacks != nil {
|
||||
// Re-publish current modeId so Agent.GetMode stays in sync.
|
||||
s.modesMu.RLock()
|
||||
available := append([]acpModeInfo(nil), s.availableModes...)
|
||||
s.modesMu.RUnlock()
|
||||
s.callbacks.reportModes(acpModesBlock{
|
||||
CurrentModeID: modeID,
|
||||
AvailableModes: available,
|
||||
})
|
||||
}
|
||||
slog.Info("acp: live mode applied", "mode", modeID, "session_id", sid)
|
||||
return true
|
||||
}
|
||||
|
||||
// matchAvailableMode resolves a user-typed mode string to a known ACP
|
||||
// modeId from the cached availableModes list. Matching is case-
|
||||
// insensitive on both id and display name to accommodate IM input.
|
||||
// Returns "" if nothing matches or if modes are unknown (first session
|
||||
// hasn't handshaked yet).
|
||||
func (s *acpSession) matchAvailableMode(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(input)
|
||||
s.modesMu.RLock()
|
||||
defer s.modesMu.RUnlock()
|
||||
for _, m := range s.availableModes {
|
||||
if strings.ToLower(m.ID) == lower || strings.ToLower(m.Name) == lower {
|
||||
return m.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *acpSession) onNotification(method string, params json.RawMessage) {
|
||||
if method != "session/update" {
|
||||
slog.Debug("acp: notification", "method", method)
|
||||
return
|
||||
}
|
||||
s.cacheToolCallInput(params)
|
||||
s.maybeAbsorbCurrentModeUpdate(params)
|
||||
sid := s.currentACPSessionID()
|
||||
for _, ev := range mapSessionUpdate(sid, params) {
|
||||
s.emit(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// maybeAbsorbCurrentModeUpdate watches session/update notifications
|
||||
// for `current_mode_update` (server-driven mode switch, e.g. when the
|
||||
// user toggles modes via the Windsurf/IDE UI while cc-connect is
|
||||
// connected). Keeping currentMode in sync here means the IM `/mode`
|
||||
// indicator reflects the true server state rather than the last
|
||||
// client-initiated value.
|
||||
func (s *acpSession) maybeAbsorbCurrentModeUpdate(params json.RawMessage) {
|
||||
var wrap struct {
|
||||
Update json.RawMessage `json:"update"`
|
||||
}
|
||||
if json.Unmarshal(params, &wrap) != nil || len(wrap.Update) == 0 {
|
||||
return
|
||||
}
|
||||
var head struct {
|
||||
Kind string `json:"sessionUpdate"`
|
||||
CurrentModeID string `json:"currentModeId"`
|
||||
}
|
||||
if json.Unmarshal(wrap.Update, &head) != nil {
|
||||
return
|
||||
}
|
||||
if head.Kind != "current_mode_update" || head.CurrentModeID == "" {
|
||||
return
|
||||
}
|
||||
s.modesMu.Lock()
|
||||
s.currentMode = head.CurrentModeID
|
||||
available := append([]acpModeInfo(nil), s.availableModes...)
|
||||
s.modesMu.Unlock()
|
||||
if s.callbacks != nil {
|
||||
s.callbacks.reportModes(acpModesBlock{
|
||||
CurrentModeID: head.CurrentModeID,
|
||||
AvailableModes: available,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// cacheToolCallInput extracts and caches rawInput from tool_call and tool_call_update
|
||||
// session updates so that handlePermissionRequest can look it up by toolCallId.
|
||||
// OpenCode ACP bug (#7370): rawInput is empty in tool_call and request_permission,
|
||||
// but populated in tool_call_update. We cache from both sources.
|
||||
func (s *acpSession) evictToolInputCacheIfNeededLocked() {
|
||||
if len(s.toolInputByID) < toolInputCacheMaxEntries {
|
||||
return
|
||||
}
|
||||
target := toolInputCacheMaxEntries / 2
|
||||
for k := range s.toolInputByID {
|
||||
if len(s.toolInputByID) <= target {
|
||||
break
|
||||
}
|
||||
delete(s.toolInputByID, k)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *acpSession) cacheToolCallInput(params json.RawMessage) {
|
||||
var wrap struct {
|
||||
Update json.RawMessage `json:"update"`
|
||||
}
|
||||
if json.Unmarshal(params, &wrap) != nil || len(wrap.Update) == 0 {
|
||||
return
|
||||
}
|
||||
var head struct {
|
||||
SessionUpdate string `json:"sessionUpdate"`
|
||||
}
|
||||
if json.Unmarshal(wrap.Update, &head) != nil {
|
||||
return
|
||||
}
|
||||
switch head.SessionUpdate {
|
||||
case "tool_call":
|
||||
var tc struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
Kind string `json:"kind"`
|
||||
RawInput json.RawMessage `json:"rawInput"`
|
||||
}
|
||||
if json.Unmarshal(wrap.Update, &tc) != nil || tc.ToolCallID == "" || len(tc.RawInput) == 0 {
|
||||
return
|
||||
}
|
||||
s.toolInputMu.Lock()
|
||||
s.evictToolInputCacheIfNeededLocked()
|
||||
input := summarizeACPToolInput(tc.Kind, tc.RawInput)
|
||||
s.toolInputByID[tc.ToolCallID] = input
|
||||
s.toolInputMu.Unlock()
|
||||
slog.Info("acp: cached tool_call input", "toolCallId", tc.ToolCallID, "kind", tc.Kind, "input", input)
|
||||
case "tool_call_update":
|
||||
var tc struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
RawInput json.RawMessage `json:"rawInput"`
|
||||
}
|
||||
if json.Unmarshal(wrap.Update, &tc) != nil || tc.ToolCallID == "" || len(tc.RawInput) == 0 {
|
||||
return
|
||||
}
|
||||
input := summarizeACPToolInput("", tc.RawInput)
|
||||
if input == "" {
|
||||
return
|
||||
}
|
||||
s.toolInputMu.Lock()
|
||||
s.evictToolInputCacheIfNeededLocked()
|
||||
s.toolInputByID[tc.ToolCallID] = input
|
||||
s.toolInputMu.Unlock()
|
||||
slog.Info("acp: cached tool_call_update input", "toolCallId", tc.ToolCallID, "input", input)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *acpSession) onServerRequest(method string, id json.RawMessage, params json.RawMessage) {
|
||||
switch method {
|
||||
case "session/request_permission":
|
||||
s.handlePermissionRequest(id, params)
|
||||
case "cursor/ask_question", "cursor/create_plan", "cursor/update_todos", "cursor/task", "cursor/generate_image":
|
||||
// Cursor CLI extensions — acknowledge so tool flows do not block; IM UX is limited for these.
|
||||
slog.Debug("acp: cursor extension request (no-op ack)", "method", method)
|
||||
_ = s.tr.respondSuccess(id, map[string]any{})
|
||||
default:
|
||||
if strings.HasPrefix(method, "cursor/") {
|
||||
slog.Debug("acp: unknown cursor extension, ack empty", "method", method)
|
||||
_ = s.tr.respondSuccess(id, map[string]any{})
|
||||
return
|
||||
}
|
||||
slog.Info("acp: unhandled server request", "method", method)
|
||||
_ = s.tr.respondError(id, -32601, "method not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *acpSession) handlePermissionRequest(id json.RawMessage, params json.RawMessage) {
|
||||
var p struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ToolCall struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
Title string `json:"title"`
|
||||
Kind string `json:"kind"`
|
||||
RawInput json.RawMessage `json:"rawInput"`
|
||||
} `json:"toolCall"`
|
||||
Options []permissionOption `json:"options"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
_ = s.tr.respondError(id, -32602, "invalid params")
|
||||
return
|
||||
}
|
||||
slog.Debug("acp: permission request raw params", "params", string(params))
|
||||
reqKey := jsonIDKey(id)
|
||||
toolName := p.ToolCall.Title
|
||||
if toolName == "" {
|
||||
toolName = p.ToolCall.Kind
|
||||
}
|
||||
if toolName == "" {
|
||||
toolName = "permission"
|
||||
}
|
||||
|
||||
s.permMu.Lock()
|
||||
s.permByID[reqKey] = permState{RPCID: id, Options: p.Options}
|
||||
s.permMu.Unlock()
|
||||
|
||||
rawTool := map[string]any{}
|
||||
_ = json.Unmarshal(params, &rawTool)
|
||||
|
||||
// OpenCode ACP bug (#7370): rawInput in request_permission is always {},
|
||||
// but tool_call_update (which arrives right after) has the real input.
|
||||
// Emit in a goroutine so we don't block the read loop, and wait briefly
|
||||
// for tool_call_update to populate the cache.
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
for i := 0; i < 10; i++ {
|
||||
s.toolInputMu.Lock()
|
||||
toolInput := s.toolInputByID[p.ToolCall.ToolCallID]
|
||||
s.toolInputMu.Unlock()
|
||||
if toolInput != "" {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
s.toolInputMu.Lock()
|
||||
toolInput := s.toolInputByID[p.ToolCall.ToolCallID]
|
||||
s.toolInputMu.Unlock()
|
||||
if toolInput == "" {
|
||||
toolInput = summarizeACPToolInput(p.ToolCall.Kind, p.ToolCall.RawInput)
|
||||
}
|
||||
if toolInput == "" {
|
||||
toolInput = p.ToolCall.Title
|
||||
}
|
||||
if toolInput == "" {
|
||||
toolInput = p.ToolCall.ToolCallID
|
||||
}
|
||||
|
||||
slog.Info("acp: permission request", "request_id", reqKey, "tool", toolName, "input", toolInput)
|
||||
s.emit(core.Event{
|
||||
Type: core.EventPermissionRequest,
|
||||
RequestID: reqKey,
|
||||
ToolName: toolName,
|
||||
ToolInput: toolInput,
|
||||
ToolInputRaw: rawTool,
|
||||
SessionID: s.currentACPSessionID(),
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *acpSession) emit(ev core.Event) {
|
||||
if ev.SessionID == "" {
|
||||
ev.SessionID = s.currentACPSessionID()
|
||||
}
|
||||
select {
|
||||
case s.events <- ev:
|
||||
case <-s.ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func (s *acpSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
|
||||
if !s.alive.Load() {
|
||||
return fmt.Errorf("acp: session closed")
|
||||
}
|
||||
|
||||
s.sendMu.Lock()
|
||||
defer s.sendMu.Unlock()
|
||||
|
||||
filePaths := core.SaveFilesToDisk(s.workDir, files)
|
||||
prompt = core.AppendFileRefs(prompt, filePaths)
|
||||
if len(images) > 0 {
|
||||
prompt = s.appendImageRefs(prompt, images)
|
||||
}
|
||||
|
||||
sid := s.currentACPSessionID()
|
||||
if sid == "" {
|
||||
return fmt.Errorf("acp: no agent session id")
|
||||
}
|
||||
|
||||
promptBlocks := []any{
|
||||
map[string]any{"type": "text", "text": prompt},
|
||||
}
|
||||
params := map[string]any{
|
||||
"sessionId": sid,
|
||||
"prompt": promptBlocks,
|
||||
}
|
||||
|
||||
_, err := s.tr.call(s.ctx, "session/prompt", params)
|
||||
if err != nil {
|
||||
s.emit(core.Event{Type: core.EventError, Error: err})
|
||||
return fmt.Errorf("acp: session/prompt: %w", err)
|
||||
}
|
||||
|
||||
// Text was streamed via session/update; engine aggregates EventText.
|
||||
s.emit(core.Event{
|
||||
Type: core.EventResult,
|
||||
SessionID: sid,
|
||||
Done: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *acpSession) appendImageRefs(prompt string, images []core.ImageAttachment) string {
|
||||
attachDir := filepath.Join(s.workDir, ".cc-connect", "attachments")
|
||||
if err := os.MkdirAll(attachDir, 0o755); err != nil {
|
||||
slog.Warn("acp: mkdir attachments failed", "error", err)
|
||||
return prompt
|
||||
}
|
||||
var paths []string
|
||||
for i, img := range images {
|
||||
ext := ".bin"
|
||||
switch img.MimeType {
|
||||
case "image/jpeg":
|
||||
ext = ".jpg"
|
||||
case "image/gif":
|
||||
ext = ".gif"
|
||||
case "image/webp":
|
||||
ext = ".webp"
|
||||
case "image/png", "":
|
||||
ext = ".png"
|
||||
}
|
||||
fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
|
||||
fpath := filepath.Join(attachDir, fname)
|
||||
if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
|
||||
slog.Error("acp: save image failed", "error", err)
|
||||
continue
|
||||
}
|
||||
paths = append(paths, fpath)
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return prompt
|
||||
}
|
||||
if prompt == "" {
|
||||
prompt = "User sent image(s)."
|
||||
}
|
||||
return prompt + "\n\n(Image files saved locally: " + strings.Join(paths, ", ") + ")"
|
||||
}
|
||||
|
||||
func (s *acpSession) RespondPermission(requestID string, result core.PermissionResult) error {
|
||||
if !s.alive.Load() {
|
||||
return fmt.Errorf("acp: session closed")
|
||||
}
|
||||
|
||||
s.permMu.Lock()
|
||||
st, ok := s.permByID[requestID]
|
||||
if ok {
|
||||
delete(s.permByID, requestID)
|
||||
}
|
||||
s.permMu.Unlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("acp: unknown permission request %q", requestID)
|
||||
}
|
||||
|
||||
allow := strings.EqualFold(result.Behavior, "allow")
|
||||
optID := pickPermissionOptionID(allow, st.Options)
|
||||
if allow && optID == "" {
|
||||
slog.Warn("acp: allow requested but agent sent no options", "request_id", requestID)
|
||||
return s.tr.respondError(st.RPCID, -32603, "no permission options from agent")
|
||||
}
|
||||
res := buildPermissionResult(allow, optID)
|
||||
|
||||
slog.Debug("acp: permission response", "request_id", requestID, "allow", allow, "option_id", optID)
|
||||
return s.tr.respondSuccess(st.RPCID, res)
|
||||
}
|
||||
|
||||
func (s *acpSession) Events() <-chan core.Event {
|
||||
return s.events
|
||||
}
|
||||
|
||||
func (s *acpSession) CurrentSessionID() string {
|
||||
return s.currentACPSessionID()
|
||||
}
|
||||
|
||||
func (s *acpSession) Alive() bool {
|
||||
return s.alive.Load()
|
||||
}
|
||||
|
||||
func (s *acpSession) Close() error {
|
||||
s.alive.Store(false)
|
||||
s.cancel()
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
close(s.events)
|
||||
case <-time.After(8 * time.Second):
|
||||
slog.Warn("acp: close timed out waiting for I/O loop")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// summarizeACPToolInput extracts a human-readable summary from ACP tool rawInput.
|
||||
func summarizeACPToolInput(kind string, raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return string(raw)
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(kind) {
|
||||
case "bash", "shell", "terminal", "execute":
|
||||
if cmd, ok := m["command"].(string); ok {
|
||||
if desc, ok := m["description"].(string); ok && desc != "" {
|
||||
return "# " + desc + "\n" + cmd
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
case "read", "write", "edit":
|
||||
if fp, ok := m["file_path"].(string); ok {
|
||||
return fp
|
||||
}
|
||||
if fp, ok := m["path"].(string); ok {
|
||||
return fp
|
||||
}
|
||||
}
|
||||
// Fallback: try extracting command with description before formatting JSON.
|
||||
if cmd, ok := m["command"].(string); ok {
|
||||
if desc, ok := m["description"].(string); ok && desc != "" {
|
||||
return "# " + desc + "\n" + cmd
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user