763 lines
21 KiB
Go
763 lines
21 KiB
Go
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)
|
|
}
|