892 lines
25 KiB
Go
892 lines
25 KiB
Go
package copilot
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
)
|
|
|
|
// copilotSession manages a long-running Copilot CLI process using
|
|
// JSON-RPC 2.0 over Content-Length framed stdio.
|
|
type copilotSession struct {
|
|
cmd *exec.Cmd
|
|
rpc *rpcClient
|
|
reader *lspReader
|
|
events chan core.Event
|
|
sessionID atomic.Value // stores string - Copilot session ID
|
|
mode string // permission mode
|
|
model string
|
|
provider *copilotWireProviderConfig
|
|
workDir string
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
done chan struct{}
|
|
alive atomic.Bool
|
|
|
|
// autoApprove is set when mode == "bypassPermissions"
|
|
autoApprove atomic.Bool
|
|
|
|
// pendingPermissions maps Copilot requestId to JSON-RPC request ids
|
|
// for permission.request server-to-client RPC calls. eventPermissions
|
|
// tracks permission.requested session events, which are answered via
|
|
// session.permissions.handlePendingPermissionRequest.
|
|
pendingPermMu sync.Mutex
|
|
pendingPermissions map[string]json.RawMessage
|
|
eventPermissions map[string]struct{}
|
|
|
|
// context usage tracking
|
|
contextMu sync.RWMutex
|
|
contextUsage *core.ContextUsage
|
|
}
|
|
|
|
type copilotWireProviderConfig struct {
|
|
Type string `json:"type,omitempty"`
|
|
WireAPI string `json:"wireApi,omitempty"`
|
|
BaseURL string `json:"baseUrl"`
|
|
APIKey string `json:"apiKey,omitempty"`
|
|
BearerToken string `json:"bearerToken,omitempty"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
ModelID string `json:"modelId,omitempty"`
|
|
WireModel string `json:"wireModel,omitempty"`
|
|
}
|
|
|
|
type copilotSessionConfig struct {
|
|
SessionID string `json:"sessionId,omitempty"`
|
|
ClientName string `json:"clientName,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
Provider *copilotWireProviderConfig `json:"provider,omitempty"`
|
|
RequestPermission *bool `json:"requestPermission,omitempty"`
|
|
WorkingDirectory string `json:"workingDirectory,omitempty"`
|
|
Streaming *bool `json:"streaming,omitempty"`
|
|
IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"`
|
|
EnvValueMode string `json:"envValueMode,omitempty"`
|
|
}
|
|
|
|
type copilotPermissionResult struct {
|
|
Kind string `json:"kind"`
|
|
Rules []any `json:"rules,omitempty"`
|
|
}
|
|
|
|
func newCopilotSession(ctx context.Context, workDir, cliBin, model, mode, resumeSessionID string, extraEnv []string, provider *copilotWireProviderConfig) (*copilotSession, error) {
|
|
sessionCtx, cancel := context.WithCancel(ctx)
|
|
|
|
args := []string{"--headless", "--stdio", "--no-auto-update"}
|
|
|
|
slog.Debug("copilotSession: starting", "bin", cliBin, "args", args, "dir", workDir)
|
|
|
|
cmd := exec.CommandContext(sessionCtx, cliBin, args...)
|
|
cmd.Dir = workDir
|
|
prepareCmdForKill(cmd)
|
|
|
|
env := os.Environ()
|
|
if len(extraEnv) > 0 {
|
|
env = core.MergeEnv(env, extraEnv)
|
|
}
|
|
cmd.Env = env
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("copilotSession: stdin pipe: %w", err)
|
|
}
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("copilotSession: stdout pipe: %w", err)
|
|
}
|
|
|
|
var stderrBuf bytes.Buffer
|
|
cmd.Stderr = &stderrBuf
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("copilotSession: start: %w", err)
|
|
}
|
|
|
|
cs := &copilotSession{
|
|
cmd: cmd,
|
|
rpc: newRPCClient(stdin),
|
|
reader: newLSPReader(stdout),
|
|
events: make(chan core.Event, 64),
|
|
mode: mode,
|
|
model: model,
|
|
provider: provider,
|
|
workDir: workDir,
|
|
ctx: sessionCtx,
|
|
cancel: cancel,
|
|
done: make(chan struct{}),
|
|
pendingPermissions: make(map[string]json.RawMessage),
|
|
eventPermissions: make(map[string]struct{}),
|
|
}
|
|
cs.alive.Store(true)
|
|
cs.autoApprove.Store(mode == "bypassPermissions")
|
|
if resumeSessionID != "" && resumeSessionID != core.ContinueSession {
|
|
cs.sessionID.Store(resumeSessionID)
|
|
}
|
|
|
|
// Start reading loop
|
|
go cs.readLoop(&stderrBuf)
|
|
|
|
// Perform handshake: ping then create/resume session
|
|
if err := cs.handshake(resumeSessionID); err != nil {
|
|
_ = cs.Close()
|
|
return nil, fmt.Errorf("copilotSession: handshake failed: %w", err)
|
|
}
|
|
|
|
return cs, nil
|
|
}
|
|
|
|
func (cs *copilotSession) handshake(resumeSessionID string) error {
|
|
// Step 1: Ping
|
|
_, pingCh := cs.rpc.call("ping", nil)
|
|
select {
|
|
case resp := <-pingCh:
|
|
if resp.Error != nil {
|
|
return fmt.Errorf("ping: %w", resp.Error)
|
|
}
|
|
slog.Debug("copilotSession: ping OK")
|
|
case <-time.After(10 * time.Second):
|
|
return fmt.Errorf("ping timeout")
|
|
case <-cs.ctx.Done():
|
|
return cs.ctx.Err()
|
|
}
|
|
|
|
// Step 2: Create or resume session
|
|
if resumeSessionID != "" && resumeSessionID != core.ContinueSession {
|
|
_, resumeCh := cs.rpc.call("session.resume", cs.sessionConfig(resumeSessionID))
|
|
select {
|
|
case resp := <-resumeCh:
|
|
if resp.Error != nil {
|
|
slog.Warn("copilotSession: resume failed, creating new session", "error", resp.Error)
|
|
return cs.createSession()
|
|
}
|
|
cs.sessionID.Store(resumeSessionID)
|
|
slog.Info("copilotSession: session resumed", "sessionId", resumeSessionID)
|
|
case <-time.After(10 * time.Second):
|
|
return fmt.Errorf("session.resume timeout")
|
|
case <-cs.ctx.Done():
|
|
return cs.ctx.Err()
|
|
}
|
|
} else {
|
|
return cs.createSession()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (cs *copilotSession) createSession() error {
|
|
_, createCh := cs.rpc.call("session.create", cs.sessionConfig(newCopilotSessionID()))
|
|
select {
|
|
case resp := <-createCh:
|
|
if resp.Error != nil {
|
|
return fmt.Errorf("session.create: %w", resp.Error)
|
|
}
|
|
var result struct {
|
|
SessionID string `json:"sessionId"`
|
|
}
|
|
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
|
return fmt.Errorf("session.create decode: %w", err)
|
|
}
|
|
cs.sessionID.Store(result.SessionID)
|
|
slog.Info("copilotSession: session created", "sessionId", result.SessionID)
|
|
case <-time.After(10 * time.Second):
|
|
return fmt.Errorf("session.create timeout")
|
|
case <-cs.ctx.Done():
|
|
return cs.ctx.Err()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (cs *copilotSession) sessionConfig(sessionID string) copilotSessionConfig {
|
|
requestPermission := true
|
|
streaming := true
|
|
includeSubAgentStreaming := true
|
|
return copilotSessionConfig{
|
|
SessionID: sessionID,
|
|
ClientName: "cc-connect",
|
|
Model: strings.TrimSpace(cs.model),
|
|
Provider: cs.provider,
|
|
RequestPermission: &requestPermission,
|
|
WorkingDirectory: cs.workDir,
|
|
Streaming: &streaming,
|
|
IncludeSubAgentStreamingEvents: &includeSubAgentStreaming,
|
|
EnvValueMode: "direct",
|
|
}
|
|
}
|
|
|
|
func newCopilotSessionID() string {
|
|
var b [16]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return fmt.Sprintf("cc-connect-%d", time.Now().UnixNano())
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
|
}
|
|
|
|
func (cs *copilotSession) readLoop(stderrBuf *bytes.Buffer) {
|
|
defer func() {
|
|
cs.alive.Store(false)
|
|
cs.rpc.cancelAll(fmt.Errorf("process exited"))
|
|
|
|
// Wait for process exit
|
|
if err := cs.cmd.Wait(); err != nil {
|
|
stderrMsg := strings.TrimSpace(stderrBuf.String())
|
|
if stderrMsg != "" {
|
|
slog.Error("copilotSession: process failed", "error", err, "stderr", stderrMsg)
|
|
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
|
|
select {
|
|
case cs.events <- evt:
|
|
case <-cs.ctx.Done():
|
|
}
|
|
}
|
|
}
|
|
close(cs.events)
|
|
close(cs.done)
|
|
}()
|
|
|
|
for {
|
|
body, err := cs.reader.readMessage()
|
|
if err != nil {
|
|
if cs.ctx.Err() != nil {
|
|
return
|
|
}
|
|
slog.Error("copilotSession: read error", "error", err)
|
|
return
|
|
}
|
|
|
|
cs.handleMessage(body)
|
|
}
|
|
}
|
|
|
|
func (cs *copilotSession) handleMessage(body []byte) {
|
|
var probe struct {
|
|
ID json.RawMessage `json:"id"`
|
|
Method string `json:"method"`
|
|
}
|
|
if err := json.Unmarshal(body, &probe); err != nil {
|
|
slog.Debug("copilotSession: invalid JSON", "error", err)
|
|
return
|
|
}
|
|
|
|
hasID := len(probe.ID) > 0 && string(probe.ID) != "null"
|
|
hasMethod := probe.Method != ""
|
|
|
|
// server-to-client request: has both id AND method
|
|
if hasID && hasMethod {
|
|
cs.handleServerRequest(probe.ID, probe.Method, body)
|
|
return
|
|
}
|
|
|
|
// Response: has id but no method
|
|
if hasID && !hasMethod {
|
|
var resp jsonRPCResponse
|
|
if err := json.Unmarshal(body, &resp); err == nil {
|
|
cs.rpc.dispatch(&resp)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Notification: no id
|
|
var notif jsonRPCNotification
|
|
if err := json.Unmarshal(body, ¬if); err != nil {
|
|
slog.Debug("copilotSession: failed to parse notification", "error", err)
|
|
return
|
|
}
|
|
cs.handleNotification(notif)
|
|
}
|
|
|
|
// handleServerRequest handles server-to-client RPC requests (have both id and method).
|
|
func (cs *copilotSession) handleServerRequest(rpcID json.RawMessage, method string, body []byte) {
|
|
slog.Debug("copilotSession: server-to-client request", "method", method)
|
|
|
|
switch method {
|
|
case "session.event":
|
|
var req struct {
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
slog.Debug("copilotSession: failed to parse session.event request", "error", err)
|
|
_ = cs.rpc.respond(rpcID, nil, &jsonRPCError{Code: -32602, Message: "invalid params"})
|
|
return
|
|
}
|
|
cs.handleSessionEvent(req.Params)
|
|
_ = cs.rpc.respond(rpcID, map[string]any{}, nil)
|
|
|
|
case "permission.request":
|
|
var req struct {
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
slog.Debug("copilotSession: failed to parse permission.request", "error", err)
|
|
_ = cs.rpc.respond(rpcID, nil, &jsonRPCError{Code: -32602, Message: "invalid params"})
|
|
return
|
|
}
|
|
permReq, err := parsePermissionRequest(req.Params)
|
|
if err != nil || permReq.RequestID == "" {
|
|
slog.Debug("copilotSession: failed to parse permission.request params", "error", err)
|
|
_ = cs.rpc.respond(rpcID, nil, &jsonRPCError{Code: -32602, Message: "invalid params"})
|
|
return
|
|
}
|
|
if permReq.Tool == "" {
|
|
permReq.Tool = permReq.ToolName
|
|
}
|
|
if permReq.Input == nil {
|
|
permReq.Input = permReq.Arguments
|
|
}
|
|
cs.pendingPermMu.Lock()
|
|
cs.pendingPermissions[permReq.RequestID] = rpcID
|
|
cs.pendingPermMu.Unlock()
|
|
cs.handlePermissionRequest(req.Params)
|
|
default:
|
|
// Send method-not-found response for unhandled server-to-client requests
|
|
_ = cs.rpc.respond(rpcID, nil, &jsonRPCError{Code: -32601, Message: "method not found"})
|
|
}
|
|
}
|
|
|
|
func (cs *copilotSession) handleNotification(notif jsonRPCNotification) {
|
|
slog.Debug("copilotSession: notification", "method", notif.Method)
|
|
|
|
switch notif.Method {
|
|
case "session.event":
|
|
cs.handleSessionEvent(notif.Params)
|
|
case "permission.request":
|
|
// Copilot may send permission.request as a notification (no id).
|
|
cs.handlePermissionRequest(notif.Params)
|
|
default:
|
|
slog.Debug("copilotSession: unhandled notification", "method", notif.Method)
|
|
}
|
|
}
|
|
|
|
// sessionEvent wraps Copilot's session.event notification params.
|
|
type sessionEvent struct {
|
|
SessionID string `json:"sessionId"`
|
|
Event sessionEventInner `json:"event"`
|
|
}
|
|
|
|
type sessionEventInner struct {
|
|
Type string `json:"type"`
|
|
Kind string `json:"kind"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
|
|
func (cs *copilotSession) handleSessionEvent(params json.RawMessage) {
|
|
var evt sessionEvent
|
|
if err := json.Unmarshal(params, &evt); err != nil {
|
|
slog.Debug("copilotSession: failed to parse session.event", "error", err)
|
|
return
|
|
}
|
|
|
|
eventType := evt.Event.Type
|
|
if eventType == "" {
|
|
eventType = evt.Event.Kind
|
|
}
|
|
eventType = normalizeCopilotEventType(eventType)
|
|
|
|
slog.Debug("copilotSession: session event", "type", eventType, "sessionId", evt.SessionID)
|
|
|
|
switch eventType {
|
|
case "assistant.message_delta":
|
|
content := copilotEventText(evt.Event.Data)
|
|
if content != "" {
|
|
e := core.Event{Type: core.EventText, Content: content}
|
|
select {
|
|
case cs.events <- e:
|
|
case <-cs.ctx.Done():
|
|
}
|
|
}
|
|
|
|
case "assistant.message":
|
|
usage := copilotEventUsage(evt.Event.Data)
|
|
if len(evt.Event.Data) > 0 {
|
|
cs.addContextUsage(usage.inputTokens, usage.outputTokens)
|
|
|
|
e := core.Event{
|
|
Type: core.EventResult,
|
|
SessionID: cs.CurrentSessionID(),
|
|
Done: true,
|
|
OutputTokens: usage.outputTokens,
|
|
}
|
|
select {
|
|
case cs.events <- e:
|
|
case <-cs.ctx.Done():
|
|
}
|
|
}
|
|
|
|
case "assistant.usage":
|
|
usage := copilotEventUsage(evt.Event.Data)
|
|
cs.addContextUsage(usage.inputTokens, usage.outputTokens)
|
|
|
|
case "permission.requested":
|
|
cs.handlePermissionRequestedEvent(evt.Event.Data)
|
|
|
|
case "assistant.turn_start":
|
|
slog.Debug("copilotSession: turn started")
|
|
|
|
case "assistant.turn_end":
|
|
slog.Debug("copilotSession: turn ended")
|
|
|
|
case "session.idle":
|
|
slog.Debug("copilotSession: session idle")
|
|
|
|
case "session.skills_loaded", "session.mcp_servers_loaded", "session.tools_updated", "session.usage_info", "session.title_changed":
|
|
slog.Debug("copilotSession: capabilities updated", "type", eventType)
|
|
|
|
case "system.message":
|
|
// System prompt notification, can be ignored
|
|
slog.Debug("copilotSession: system message received")
|
|
|
|
case "pending_messages.modified", "custom_agents_updated", "session.start", "assistant.message_start", "assistant.streaming_delta", "response", "messages_snapshot":
|
|
// Known informational events; no action required
|
|
slog.Debug("copilotSession: informational event", "type", eventType)
|
|
|
|
default:
|
|
slog.Debug("copilotSession: unhandled session event", "type", eventType)
|
|
}
|
|
}
|
|
|
|
// permissionRequest represents Copilot's permission.request notification.
|
|
type permissionRequest struct {
|
|
SessionID string `json:"sessionId"`
|
|
RequestID string `json:"requestId"`
|
|
Tool string `json:"tool"`
|
|
ToolName string `json:"toolName"`
|
|
Kind string `json:"kind"`
|
|
Input map[string]any `json:"input"`
|
|
Arguments map[string]any `json:"arguments"`
|
|
}
|
|
|
|
func (cs *copilotSession) handlePermissionRequest(params json.RawMessage) {
|
|
req, err := parsePermissionRequest(params)
|
|
if err != nil {
|
|
slog.Debug("copilotSession: failed to parse permission request", "error", err)
|
|
return
|
|
}
|
|
cs.emitPermissionRequest(req)
|
|
}
|
|
|
|
func normalizeCopilotEventType(eventType string) string {
|
|
switch eventType {
|
|
case "assistant_message":
|
|
return "assistant.message"
|
|
case "assistant_usage":
|
|
return "assistant.usage"
|
|
case "assistant_turn_start":
|
|
return "assistant.turn_start"
|
|
case "assistant_turn_end":
|
|
return "assistant.turn_end"
|
|
case "assistant_message_start":
|
|
return "assistant.message_start"
|
|
case "assistant_message_delta":
|
|
return "assistant.message_delta"
|
|
case "assistant_streaming_delta":
|
|
return "assistant.streaming_delta"
|
|
}
|
|
return eventType
|
|
}
|
|
|
|
func copilotEventText(raw json.RawMessage) string {
|
|
var data struct {
|
|
DeltaContent string `json:"deltaContent"`
|
|
Content string `json:"content"`
|
|
Text string `json:"text"`
|
|
Delta string `json:"delta"`
|
|
}
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
return ""
|
|
}
|
|
for _, s := range []string{data.DeltaContent, data.Content, data.Text, data.Delta} {
|
|
if s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type copilotUsage struct {
|
|
inputTokens int
|
|
outputTokens int
|
|
}
|
|
|
|
func copilotEventUsage(raw json.RawMessage) copilotUsage {
|
|
var data struct {
|
|
InputTokens int `json:"inputTokens"`
|
|
OutputTokens int `json:"outputTokens"`
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
Usage struct {
|
|
InputTokens int `json:"inputTokens"`
|
|
OutputTokens int `json:"outputTokens"`
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
} `json:"usage"`
|
|
}
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
return copilotUsage{}
|
|
}
|
|
usage := copilotUsage{inputTokens: data.InputTokens, outputTokens: data.OutputTokens}
|
|
if usage.inputTokens == 0 {
|
|
usage.inputTokens = data.PromptTokens
|
|
}
|
|
if usage.outputTokens == 0 {
|
|
usage.outputTokens = data.CompletionTokens
|
|
}
|
|
if usage.inputTokens == 0 {
|
|
usage.inputTokens = data.Usage.InputTokens
|
|
}
|
|
if usage.inputTokens == 0 {
|
|
usage.inputTokens = data.Usage.PromptTokens
|
|
}
|
|
if usage.outputTokens == 0 {
|
|
usage.outputTokens = data.Usage.OutputTokens
|
|
}
|
|
if usage.outputTokens == 0 {
|
|
usage.outputTokens = data.Usage.CompletionTokens
|
|
}
|
|
return usage
|
|
}
|
|
|
|
func (cs *copilotSession) addContextUsage(inputTokens, outputTokens int) {
|
|
if inputTokens == 0 && outputTokens == 0 {
|
|
return
|
|
}
|
|
cs.contextMu.Lock()
|
|
defer cs.contextMu.Unlock()
|
|
if cs.contextUsage == nil {
|
|
cs.contextUsage = &core.ContextUsage{}
|
|
}
|
|
cs.contextUsage.OutputTokens += outputTokens
|
|
cs.contextUsage.InputTokens += inputTokens
|
|
cs.contextUsage.TotalTokens = cs.contextUsage.InputTokens + cs.contextUsage.OutputTokens
|
|
cs.contextUsage.UsedTokens = cs.contextUsage.TotalTokens
|
|
}
|
|
|
|
func (cs *copilotSession) handlePermissionRequestedEvent(data json.RawMessage) {
|
|
var evt struct {
|
|
RequestID string `json:"requestId"`
|
|
PermissionRequest json.RawMessage `json:"permissionRequest"`
|
|
}
|
|
if err := json.Unmarshal(data, &evt); err != nil || evt.RequestID == "" || len(evt.PermissionRequest) == 0 {
|
|
slog.Debug("copilotSession: failed to parse permission.requested event", "error", err)
|
|
return
|
|
}
|
|
|
|
req, err := parsePermissionRequest(evt.PermissionRequest)
|
|
if err != nil {
|
|
slog.Debug("copilotSession: failed to parse permission.requested payload", "error", err)
|
|
return
|
|
}
|
|
req.RequestID = evt.RequestID
|
|
if req.Input == nil {
|
|
req.Input = rawJSONMap(evt.PermissionRequest)
|
|
}
|
|
|
|
cs.pendingPermMu.Lock()
|
|
if cs.eventPermissions == nil {
|
|
cs.eventPermissions = make(map[string]struct{})
|
|
}
|
|
cs.eventPermissions[req.RequestID] = struct{}{}
|
|
cs.pendingPermMu.Unlock()
|
|
|
|
cs.emitPermissionRequest(req)
|
|
}
|
|
|
|
func (cs *copilotSession) emitPermissionRequest(req permissionRequest) {
|
|
if req.Tool == "" {
|
|
req.Tool = req.ToolName
|
|
}
|
|
if req.Tool == "" {
|
|
req.Tool = strings.ReplaceAll(req.Kind, "_", ".")
|
|
}
|
|
if req.Input == nil {
|
|
req.Input = req.Arguments
|
|
}
|
|
|
|
slog.Info("copilotSession: permission request", "requestId", req.RequestID, "tool", req.Tool)
|
|
|
|
if cs.autoApprove.Load() {
|
|
slog.Debug("copilotSession: auto-approving", "requestId", req.RequestID, "tool", req.Tool)
|
|
_ = cs.RespondPermission(req.RequestID, core.PermissionResult{
|
|
Behavior: "allow",
|
|
UpdatedInput: req.Input,
|
|
})
|
|
return
|
|
}
|
|
|
|
inputSummary := summarizeToolInput(req.Tool, req.Input)
|
|
evt := core.Event{
|
|
Type: core.EventPermissionRequest,
|
|
RequestID: req.RequestID,
|
|
ToolName: req.Tool,
|
|
ToolInput: inputSummary,
|
|
ToolInputRaw: req.Input,
|
|
}
|
|
select {
|
|
case cs.events <- evt:
|
|
case <-cs.ctx.Done():
|
|
}
|
|
}
|
|
|
|
func parsePermissionRequest(params json.RawMessage) (permissionRequest, error) {
|
|
var req permissionRequest
|
|
if err := json.Unmarshal(params, &req); err != nil {
|
|
return permissionRequest{}, err
|
|
}
|
|
if req.RequestID != "" || req.Tool != "" || req.ToolName != "" || req.Kind != "" {
|
|
return req, nil
|
|
}
|
|
var wrapped struct {
|
|
SessionID string `json:"sessionId"`
|
|
PermissionRequest permissionRequest `json:"permissionRequest"`
|
|
}
|
|
if err := json.Unmarshal(params, &wrapped); err != nil {
|
|
return permissionRequest{}, err
|
|
}
|
|
wrapped.PermissionRequest.SessionID = wrapped.SessionID
|
|
return wrapped.PermissionRequest, nil
|
|
}
|
|
|
|
func rawJSONMap(raw json.RawMessage) map[string]any {
|
|
var out map[string]any
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func summarizeToolInput(tool string, input map[string]any) string {
|
|
if input == nil {
|
|
return ""
|
|
}
|
|
b, err := json.Marshal(input)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
s := string(b)
|
|
if len(s) > 200 {
|
|
s = s[:200] + "..."
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Send sends a user message to the running Copilot process.
|
|
func (cs *copilotSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
|
|
if !cs.alive.Load() {
|
|
return fmt.Errorf("session process is not running")
|
|
}
|
|
|
|
// Handle images: save to temp dir and append file references
|
|
if len(images) > 0 {
|
|
imgPaths, err := saveImagesToTempDir(cs.workDir, images)
|
|
if err != nil {
|
|
slog.Warn("copilotSession: failed to save images", "error", err)
|
|
} else {
|
|
prompt = core.AppendFileRefs(prompt, imgPaths)
|
|
}
|
|
}
|
|
|
|
// Handle files
|
|
if len(files) > 0 {
|
|
filePaths := core.SaveFilesToDisk(cs.workDir, files)
|
|
prompt = core.AppendFileRefs(prompt, filePaths)
|
|
}
|
|
|
|
sid := cs.CurrentSessionID()
|
|
if sid == "" {
|
|
return fmt.Errorf("no active session")
|
|
}
|
|
|
|
params := map[string]any{
|
|
"sessionId": sid,
|
|
"prompt": prompt,
|
|
}
|
|
|
|
_, sendCh := cs.rpc.call("session.send", params)
|
|
|
|
// Don't block - just validate the send was accepted
|
|
go func() {
|
|
select {
|
|
case resp := <-sendCh:
|
|
if resp.Error != nil {
|
|
slog.Error("copilotSession: send failed", "error", resp.Error)
|
|
if cs.alive.Load() {
|
|
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("send: %s", resp.Error.Message)}
|
|
select {
|
|
case cs.events <- evt:
|
|
case <-cs.ctx.Done():
|
|
}
|
|
}
|
|
} else {
|
|
slog.Debug("copilotSession: send accepted")
|
|
}
|
|
case <-cs.ctx.Done():
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// RespondPermission sends a permission decision back to the Copilot process.
|
|
// If the permission request came as a server-to-client RPC request (has a JSON-RPC id),
|
|
// a proper JSON-RPC response is sent; otherwise a notification is used.
|
|
func (cs *copilotSession) RespondPermission(requestID string, result core.PermissionResult) error {
|
|
if !cs.alive.Load() {
|
|
return fmt.Errorf("session process is not running")
|
|
}
|
|
|
|
decision := copilotPermissionResult{Kind: copilotPermissionKind(result)}
|
|
|
|
// Check if this permission came as a server-to-client RPC request
|
|
cs.pendingPermMu.Lock()
|
|
rpcID, hasRPCID := cs.pendingPermissions[requestID]
|
|
if hasRPCID {
|
|
delete(cs.pendingPermissions, requestID)
|
|
}
|
|
_, hasEventPermission := cs.eventPermissions[requestID]
|
|
if hasEventPermission {
|
|
delete(cs.eventPermissions, requestID)
|
|
}
|
|
cs.pendingPermMu.Unlock()
|
|
|
|
if hasRPCID {
|
|
return cs.rpc.respond(rpcID, decision, nil)
|
|
}
|
|
if hasEventPermission {
|
|
_, ch := cs.rpc.call("session.permissions.handlePendingPermissionRequest", map[string]any{
|
|
"sessionId": cs.CurrentSessionID(),
|
|
"requestId": requestID,
|
|
"result": decision,
|
|
})
|
|
go func() {
|
|
select {
|
|
case resp := <-ch:
|
|
if resp.Error != nil {
|
|
slog.Error("copilotSession: permission response failed", "error", resp.Error)
|
|
}
|
|
case <-cs.ctx.Done():
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
return cs.rpc.notify("permission.respond", map[string]any{
|
|
"requestId": requestID,
|
|
"result": decision,
|
|
})
|
|
}
|
|
|
|
func copilotPermissionKind(result core.PermissionResult) string {
|
|
if result.Behavior == "allow" {
|
|
return "approve-once"
|
|
}
|
|
if strings.TrimSpace(result.Message) == "denied: no active user turn" {
|
|
return "user-not-available"
|
|
}
|
|
return "reject"
|
|
}
|
|
|
|
func (cs *copilotSession) Events() <-chan core.Event {
|
|
return cs.events
|
|
}
|
|
|
|
func (cs *copilotSession) CurrentSessionID() string {
|
|
v, _ := cs.sessionID.Load().(string)
|
|
return v
|
|
}
|
|
|
|
func (cs *copilotSession) Alive() bool {
|
|
return cs.alive.Load()
|
|
}
|
|
|
|
// GetContextUsage implements core.ContextUsageReporter.
|
|
func (cs *copilotSession) GetContextUsage() *core.ContextUsage {
|
|
cs.contextMu.RLock()
|
|
defer cs.contextMu.RUnlock()
|
|
if cs.contextUsage == nil {
|
|
return nil
|
|
}
|
|
cu := *cs.contextUsage
|
|
return &cu
|
|
}
|
|
|
|
var (
|
|
copilotGracefulTimeout = 3 * time.Second
|
|
copilotSigtermWait = 5 * time.Second
|
|
)
|
|
|
|
func (cs *copilotSession) Close() error {
|
|
// Close stdin to signal EOF
|
|
if w, ok := cs.rpc.writer.w.(io.Closer); ok {
|
|
_ = w.Close()
|
|
}
|
|
|
|
select {
|
|
case <-cs.done:
|
|
slog.Info("copilotSession: exited cleanly after stdin close")
|
|
return nil
|
|
case <-time.After(copilotGracefulTimeout):
|
|
slog.Warn("copilotSession: graceful stop timed out, sending SIGTERM")
|
|
}
|
|
|
|
terminateCmd(cs.cmd)
|
|
|
|
select {
|
|
case <-cs.done:
|
|
slog.Info("copilotSession: exited after SIGTERM")
|
|
return nil
|
|
case <-time.After(copilotSigtermWait):
|
|
slog.Warn("copilotSession: SIGTERM timed out, sending SIGKILL")
|
|
}
|
|
|
|
cs.cancel()
|
|
_ = forceKillCmd(cs.cmd)
|
|
<-cs.done
|
|
return nil
|
|
}
|
|
|
|
// saveImagesToTempDir saves image attachments to a temp directory under workDir
|
|
// and returns their file paths for inclusion in the prompt.
|
|
func saveImagesToTempDir(workDir string, images []core.ImageAttachment) ([]string, error) {
|
|
imgDir := filepath.Join(workDir, ".cc-connect", "images")
|
|
if err := os.MkdirAll(imgDir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("saveImagesToTempDir: mkdir: %w", err)
|
|
}
|
|
|
|
paths := make([]string, 0, len(images))
|
|
for i, img := range images {
|
|
ext := imageExt(img.MimeType)
|
|
fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
|
|
fpath := filepath.Join(imgDir, fname)
|
|
if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
|
|
return nil, fmt.Errorf("saveImagesToTempDir: write %s: %w", fname, err)
|
|
}
|
|
paths = append(paths, fpath)
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
func imageExt(mimeType string) string {
|
|
switch strings.ToLower(mimeType) {
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/gif":
|
|
return ".gif"
|
|
case "image/webp":
|
|
return ".webp"
|
|
default:
|
|
return ".jpg"
|
|
}
|
|
}
|