初始化仓库
This commit is contained in:
@@ -0,0 +1,891 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user