refactor: unify permission requests into agent event stream (#31)

This commit is contained in:
João Salavisa
2026-01-17 22:21:10 +00:00
committed by GitHub
parent dce2c6fe40
commit dd68da4275
45 changed files with 1507 additions and 1225 deletions
+37 -69
View File
@@ -322,13 +322,13 @@ func main() {
var containerMgr *lifecycle.ContainerManager
switch cfg.Agent.Runtime {
case "standalone":
standaloneCtl := agentctl.NewStandaloneCtl(
controlClient := agentctl.NewControlClient(
cfg.Agent.StandaloneHost,
cfg.Agent.StandalonePort,
log,
)
agentRuntime = lifecycle.NewStandaloneRuntime(
standaloneCtl,
controlClient,
cfg.Agent.StandaloneHost,
cfg.Agent.StandalonePort,
log,
@@ -421,7 +421,8 @@ func main() {
taskSvc.SetExecutionStopper(orchestratorSvc)
// Set up comment creator for saving agent responses as comments
orchestratorSvc.SetMessageCreator(&messageCreatorAdapter{svc: taskSvc})
msgCreator := &messageCreatorAdapter{svc: taskSvc}
orchestratorSvc.SetMessageCreator(msgCreator)
// ============================================
// WEBSOCKET GATEWAY (All communication via WebSocket)
@@ -554,44 +555,8 @@ func main() {
})
log.Info("Historical logs provider configured for task subscriptions (using messages and git status)")
// Set up pending permissions provider for task subscriptions
gateway.Hub.SetPendingPermissionsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) {
if lifecycleMgr == nil {
return nil, nil
}
session, err := taskRepo.GetActiveTaskSessionByTaskID(ctx, taskID)
if err != nil || session == nil || session.ID == "" {
return nil, nil
}
permissions, err := lifecycleMgr.GetPendingPermissionsForSession(ctx, session.ID)
if err != nil {
return nil, err
}
result := make([]*ws.Message, 0, len(permissions))
for _, p := range permissions {
// Build payload matching the permission.requested format
payload := map[string]interface{}{
"type": "permission_request",
"task_id": taskID,
"pending_id": p.PendingID,
"session_id": p.SessionID,
"tool_call_id": p.ToolCallID,
"title": p.Title,
"options": p.Options,
"action_type": p.ActionType,
"action_details": p.ActionDetails,
"created_at": p.CreatedAt.Format(time.RFC3339),
}
notification, err := ws.NewNotification(ws.ActionPermissionRequested, payload)
if err != nil {
continue
}
result = append(result, notification)
}
return result, nil
})
log.Info("Pending permissions provider configured for task subscriptions")
// NOTE: Pending permissions are now stored as messages and retrieved via the historical logs provider.
// The separate pending permissions provider is no longer needed.
// NOTE: We no longer create comments for each ACP message chunk.
// Instead, the lifecycle manager accumulates message chunks and:
@@ -600,35 +565,9 @@ func main() {
// This is handled via the prompt.complete and step.complete events.
// Wire ACP handler to broadcast to WebSocket clients as notifications
// NOTE: Permission requests are now handled via message creation in the lifecycle manager
// and broadcast automatically via the session.message.added event
orchestratorSvc.RegisterACPHandler(func(taskID string, msg *protocol.Message) {
// Check for permission_request events (type is in msg.Type, payload in msg.Data)
if msg.Type == "permission_request" {
sessionID := msg.SessionID
if sessionID == "" {
log.Warn("permission_request missing session_id, skipping",
zap.String("task_id", taskID))
return
}
// Build payload for frontend with all necessary fields
payload := map[string]interface{}{
"type": "permission_request",
"task_id": taskID,
"agent_instance_id": msg.AgentID,
"timestamp": msg.Timestamp,
}
// Copy all fields from msg.Data (pending_id, options, action_type, etc.)
for k, v := range msg.Data {
payload[k] = v
}
// Broadcast permission.requested notification to task subscribers
notification, _ := ws.NewNotification(ws.ActionPermissionRequested, payload)
gateway.Hub.BroadcastToSession(sessionID, notification)
log.Info("broadcasted permission.requested notification",
zap.String("task_id", taskID),
zap.Any("pending_id", payload["pending_id"]))
return
}
// Only broadcast message types that the frontend handles
// Skip internal message types like session/update
switch msg.Type {
@@ -1311,3 +1250,32 @@ func (a *messageCreatorAdapter) CreateSessionMessage(ctx context.Context, taskID
})
return err
}
// CreatePermissionRequestMessage creates a message for a permission request
func (a *messageCreatorAdapter) CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, pendingID, toolCallID, title string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error) {
metadata := map[string]interface{}{
"pending_id": pendingID,
"tool_call_id": toolCallID,
"options": options,
"action_type": actionType,
"action_details": actionDetails,
}
msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: sessionID,
TaskID: taskID,
Content: title,
AuthorType: "agent",
Type: "permission_request",
Metadata: metadata,
})
if err != nil {
return "", err
}
return msg.ID, nil
}
// UpdatePermissionMessage updates a permission message's status
func (a *messageCreatorAdapter) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID, status string) error {
return a.svc.UpdatePermissionMessage(ctx, sessionID, pendingID, status)
}
@@ -78,8 +78,8 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe
containerIP = "127.0.0.1"
}
// Create StandaloneCtl to communicate with the container's ControlServer
ctl := agentctl.NewStandaloneCtl(containerIP, AgentCtlPort, cm.logger)
// Create ControlClient to communicate with the container's control server
ctl := agentctl.NewControlClient(containerIP, AgentCtlPort, cm.logger)
// Wait for agentctl to be healthy
if err := cm.waitForHealth(ctx, ctl); err != nil {
@@ -115,7 +115,7 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe
}
// waitForHealth waits for agentctl to be healthy with retries
func (cm *ContainerManager) waitForHealth(ctx context.Context, ctl *agentctl.StandaloneCtl) error {
func (cm *ContainerManager) waitForHealth(ctx context.Context, ctl *agentctl.ControlClient) error {
const maxRetries = 30
const retryDelay = 500 * time.Millisecond
+59 -67
View File
@@ -114,63 +114,64 @@ func (p *EventPublisher) PublishACPSessionCreated(execution *AgentExecution, ses
}
}
// PublishSessionUpdate publishes a session update to the event bus for WebSocket streaming.
func (p *EventPublisher) PublishSessionUpdate(execution *AgentExecution, update agentctl.SessionUpdate) {
// PublishAgentStreamEvent publishes an agent stream event to the event bus for WebSocket streaming.
// This is different from PublishAgentEvent which publishes lifecycle events (started, stopped, etc.).
func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, event agentctl.AgentEvent) {
if p.eventBus == nil {
return
}
// Build the update data - our SessionUpdate type marshals cleanly
updateData := map[string]interface{}{
"type": update.Type,
// Build the event data - our AgentEvent type marshals cleanly
eventData := map[string]interface{}{
"type": event.Type,
}
if update.SessionID != "" {
updateData["session_id"] = update.SessionID
if event.SessionID != "" {
eventData["session_id"] = event.SessionID
}
if update.Text != "" {
updateData["text"] = update.Text
if event.Text != "" {
eventData["text"] = event.Text
}
if update.ToolCallID != "" {
updateData["tool_call_id"] = update.ToolCallID
if event.ToolCallID != "" {
eventData["tool_call_id"] = event.ToolCallID
}
if update.ToolName != "" {
updateData["tool_name"] = update.ToolName
if event.ToolName != "" {
eventData["tool_name"] = event.ToolName
}
if update.ToolTitle != "" {
updateData["tool_title"] = update.ToolTitle
if event.ToolTitle != "" {
eventData["tool_title"] = event.ToolTitle
}
if update.ToolStatus != "" {
updateData["tool_status"] = update.ToolStatus
if event.ToolStatus != "" {
eventData["tool_status"] = event.ToolStatus
}
if update.ToolArgs != nil {
updateData["tool_args"] = update.ToolArgs
if event.ToolArgs != nil {
eventData["tool_args"] = event.ToolArgs
}
if update.ToolResult != nil {
updateData["tool_result"] = update.ToolResult
if event.ToolResult != nil {
eventData["tool_result"] = event.ToolResult
}
if update.Error != "" {
updateData["error"] = update.Error
if event.Error != "" {
eventData["error"] = event.Error
}
if update.Data != nil {
updateData["data"] = update.Data
if event.Data != nil {
eventData["data"] = event.Data
}
// Build ACP message data
// Build agent event message data
data := map[string]interface{}{
"type": "session/update",
"type": "agent/event",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": execution.ID,
"task_id": execution.TaskID,
"session_id": update.SessionID,
"data": updateData,
"session_id": event.SessionID,
"data": eventData,
}
event := bus.NewEvent(events.ACPMessage, "agent-manager", data)
busEvent := bus.NewEvent(events.ACPMessage, "agent-manager", data)
subject := events.BuildACPSubject(execution.TaskID)
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
p.logger.Error("failed to publish session update",
if err := p.eventBus.Publish(context.Background(), subject, busEvent); err != nil {
p.logger.Error("failed to publish agent stream event",
zap.String("instance_id", execution.ID),
zap.String("task_id", execution.TaskID),
zap.Error(err))
@@ -320,8 +321,8 @@ func (p *EventPublisher) PublishToolCall(execution *AgentExecution, toolCallID,
}
}
// PublishToolCallComplete publishes a tool call completion event from a SessionUpdate.
func (p *EventPublisher) PublishToolCallComplete(execution *AgentExecution, update agentctl.SessionUpdate) {
// PublishToolCallComplete publishes a tool call completion event from an AgentEvent.
func (p *EventPublisher) PublishToolCallComplete(execution *AgentExecution, event agentctl.AgentEvent) {
if p.eventBus == nil {
return
}
@@ -334,14 +335,14 @@ func (p *EventPublisher) PublishToolCallComplete(execution *AgentExecution, upda
"agent_id": execution.ID,
"task_id": execution.TaskID,
"session_id": sessionID,
"tool_call_id": update.ToolCallID,
"status": update.ToolStatus,
"tool_call_id": event.ToolCallID,
"status": event.ToolStatus,
}
event := bus.NewEvent(events.ToolCallComplete, "agent-manager", data)
busEvent := bus.NewEvent(events.ToolCallComplete, "agent-manager", data)
subject := events.ToolCallComplete + "." + execution.TaskID
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
if err := p.eventBus.Publish(context.Background(), subject, busEvent); err != nil {
p.logger.Error("failed to publish tool_call_complete event",
zap.String("instance_id", execution.ID),
zap.String("task_id", execution.TaskID),
@@ -349,16 +350,15 @@ func (p *EventPublisher) PublishToolCallComplete(execution *AgentExecution, upda
}
}
// PublishPermissionRequest publishes a permission request event.
// The event is structured as a protocol.Message so it can be parsed by the watcher.
func (p *EventPublisher) PublishPermissionRequest(execution *AgentExecution, notification *agentctl.PermissionNotification) {
// PublishPermissionRequest publishes a permission request event to the event bus.
func (p *EventPublisher) PublishPermissionRequest(execution *AgentExecution, event agentctl.AgentEvent) {
if p.eventBus == nil {
return
}
// Convert options to a serializable format
options := make([]map[string]interface{}, len(notification.Options))
for i, opt := range notification.Options {
options := make([]map[string]interface{}, len(event.PermissionOptions))
for i, opt := range event.PermissionOptions {
options[i] = map[string]interface{}{
"option_id": opt.OptionID,
"name": opt.Name,
@@ -366,32 +366,24 @@ func (p *EventPublisher) PublishPermissionRequest(execution *AgentExecution, not
}
}
// Build payload data (goes into msg.Data when parsed as protocol.Message)
payloadData := map[string]interface{}{
"pending_id": notification.PendingID,
"session_id": notification.SessionID,
"tool_call_id": notification.ToolCallID,
"title": notification.Title,
"options": options,
"action_type": notification.ActionType,
"action_details": notification.ActionDetails,
"created_at": notification.CreatedAt,
}
// Structure as protocol.Message so watcher can parse it correctly
data := map[string]interface{}{
"type": "permission_request",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": execution.ID,
"task_id": execution.TaskID,
"session_id": notification.SessionID,
"data": payloadData,
"type": "permission_request",
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"agent_id": execution.ID,
"task_id": execution.TaskID,
"session_id": execution.SessionID,
"pending_id": event.PendingID,
"tool_call_id": event.ToolCallID,
"title": event.PermissionTitle,
"options": options,
"action_type": event.ActionType,
"action_details": event.ActionDetails,
}
event := bus.NewEvent(events.ACPMessage, "agent-manager", data)
subject := events.BuildACPSubject(execution.TaskID)
busEvent := bus.NewEvent(events.PermissionRequestReceived, "agent-manager", data)
subject := events.PermissionRequestReceived + "." + execution.TaskID
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
if err := p.eventBus.Publish(context.Background(), subject, busEvent); err != nil {
p.logger.Error("failed to publish permission_request event",
zap.String("instance_id", execution.ID),
zap.String("task_id", execution.TaskID),
@@ -399,7 +391,7 @@ func (p *EventPublisher) PublishPermissionRequest(execution *AgentExecution, not
} else {
p.logger.Debug("published permission_request event",
zap.String("task_id", execution.TaskID),
zap.String("pending_id", notification.PendingID),
zap.String("title", notification.Title))
zap.String("pending_id", event.PendingID),
zap.String("title", event.PermissionTitle))
}
}
@@ -99,10 +99,9 @@ func NewManager(
// Initialize stream manager with callbacks that delegate to manager methods
mgr.streamManager = NewStreamManager(log, StreamCallbacks{
OnSessionUpdate: mgr.handleSessionUpdate,
OnPermission: mgr.handlePermissionNotification,
OnGitStatus: mgr.handleGitStatusUpdate,
OnFileChange: mgr.handleFileChangeNotification,
OnAgentEvent: mgr.handleAgentEvent,
OnGitStatus: mgr.handleGitStatusUpdate,
OnFileChange: mgr.handleFileChangeNotification,
})
// Set session manager dependencies for full orchestration
@@ -708,34 +707,33 @@ func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExec
return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, m.MarkReady)
}
// handlePermissionNotification processes incoming permission requests from the agent
func (m *Manager) handlePermissionNotification(execution *AgentExecution, notification *agentctl.PermissionNotification) {
// Publish permission request event to the event bus (logged at Debug level there)
m.eventPublisher.PublishPermissionRequest(execution, notification)
// handlePermissionRequestEvent processes permission requests from the unified agent event stream
func (m *Manager) handlePermissionRequestEvent(execution *AgentExecution, event agentctl.AgentEvent) {
m.eventPublisher.PublishPermissionRequest(execution, event)
}
// handleSessionUpdate processes incoming session updates from the agent
func (m *Manager) handleSessionUpdate(execution *AgentExecution, update agentctl.SessionUpdate) {
// Handle different update types based on the Type field
switch update.Type {
// handleAgentEvent processes incoming agent events from the agent
func (m *Manager) handleAgentEvent(execution *AgentExecution, event agentctl.AgentEvent) {
// Handle different event types based on the Type field
switch event.Type {
case "message_chunk":
m.updateExecutionProgress(execution.ID, 50)
// Accumulate message content for saving as comment when a step completes
if update.Text != "" {
if event.Text != "" {
execution.messageMu.Lock()
execution.messageBuffer.WriteString(update.Text)
execution.messageBuffer.WriteString(event.Text)
execution.messageMu.Unlock()
}
case "reasoning":
// Accumulate reasoning/thinking content
execution.messageMu.Lock()
if update.ReasoningText != "" {
execution.reasoningBuffer.WriteString(update.ReasoningText)
if event.ReasoningText != "" {
execution.reasoningBuffer.WriteString(event.ReasoningText)
}
if update.ReasoningSummary != "" {
execution.summaryBuffer.WriteString(update.ReasoningSummary)
if event.ReasoningSummary != "" {
execution.summaryBuffer.WriteString(event.ReasoningSummary)
}
execution.messageMu.Unlock()
@@ -746,21 +744,21 @@ func (m *Manager) handleSessionUpdate(execution *AgentExecution, update agentctl
m.logger.Debug("tool call started",
zap.String("execution_id", execution.ID),
zap.String("tool_call_id", update.ToolCallID),
zap.String("tool_name", update.ToolName))
zap.String("tool_call_id", event.ToolCallID),
zap.String("tool_name", event.ToolName))
m.updateExecutionProgress(execution.ID, 60)
// Publish tool call as a comment so it appears in the chat
m.eventPublisher.PublishToolCall(execution, update.ToolCallID, update.ToolTitle, update.ToolStatus, update.ToolArgs)
m.eventPublisher.PublishToolCall(execution, event.ToolCallID, event.ToolTitle, event.ToolStatus, event.ToolArgs)
case "tool_update":
// Check if tool call completed
switch update.ToolStatus {
switch event.ToolStatus {
case "complete", "completed":
m.updateExecutionProgress(execution.ID, 80)
m.eventPublisher.PublishToolCallComplete(execution, update)
m.eventPublisher.PublishToolCallComplete(execution, event)
case "error", "failed":
m.eventPublisher.PublishToolCallComplete(execution, update)
m.eventPublisher.PublishToolCallComplete(execution, event)
}
case "plan":
@@ -770,12 +768,12 @@ func (m *Manager) handleSessionUpdate(execution *AgentExecution, update agentctl
case "error":
m.logger.Error("agent error",
zap.String("execution_id", execution.ID),
zap.String("error", update.Error))
zap.String("error", event.Error))
case "complete":
m.logger.Info("agent turn complete",
zap.String("execution_id", execution.ID),
zap.String("session_id", update.SessionID))
zap.String("session_id", event.SessionID))
// Flush accumulated message buffer as a comment
m.flushMessageBufferAsComment(execution)
@@ -786,10 +784,19 @@ func (m *Manager) handleSessionUpdate(execution *AgentExecution, update agentctl
zap.String("execution_id", execution.ID),
zap.Error(err))
}
case "permission_request":
m.logger.Debug("permission request received",
zap.String("execution_id", execution.ID),
zap.String("pending_id", event.PendingID),
zap.String("title", event.PermissionTitle))
// Handle permission request inline
m.handlePermissionRequestEvent(execution, event)
}
// Publish session update to event bus for WebSocket streaming
m.eventPublisher.PublishSessionUpdate(execution, update)
// Publish agent stream event to event bus for WebSocket streaming
m.eventPublisher.PublishAgentStreamEvent(execution, event)
}
// handleGitStatusUpdate processes git status updates from the workspace tracker
@@ -1195,17 +1202,4 @@ func (m *Manager) RespondToPermissionBySessionID(sessionID, pendingID, optionID
return m.RespondToPermission(execution.ID, pendingID, optionID, cancelled)
}
// GetPendingPermissionsForSession returns all pending permissions for a session
func (m *Manager) GetPendingPermissionsForSession(ctx context.Context, sessionID string) ([]agentctl.PermissionNotification, error) {
execution, exists := m.executionStore.GetBySessionID(sessionID)
if !exists {
// No active execution for this session, so no pending permissions
return nil, nil
}
if execution.agentctl == nil {
return nil, nil
}
return execution.agentctl.GetPendingPermissions(ctx)
}
@@ -154,8 +154,8 @@ func (r *DockerRuntime) RecoverInstances(ctx context.Context) ([]*RuntimeInstanc
containerIP = "127.0.0.1"
}
// Query the ControlServer to get the instance port
ctl := agentctl.NewStandaloneCtl(containerIP, AgentCtlPort, r.logger)
// Query the control server to get the instance port
ctl := agentctl.NewControlClient(containerIP, AgentCtlPort, r.logger)
instanceInfo, err := ctl.GetInstance(ctx, instanceID)
if err != nil {
r.logger.Warn("failed to get instance info from container",
@@ -14,14 +14,14 @@ import (
// StandaloneRuntime implements Runtime for standalone agentctl execution.
// In this mode, a single agentctl control server manages multiple agent instances.
type StandaloneRuntime struct {
ctl *agentctl.StandaloneCtl
ctl *agentctl.ControlClient
host string
port int
logger *logger.Logger
}
// NewStandaloneRuntime creates a new standalone runtime.
func NewStandaloneRuntime(ctl *agentctl.StandaloneCtl, host string, port int, log *logger.Logger) *StandaloneRuntime {
func NewStandaloneRuntime(ctl *agentctl.ControlClient, host string, port int, log *logger.Logger) *StandaloneRuntime {
return &StandaloneRuntime{
ctl: ctl,
host: host,
@@ -12,10 +12,9 @@ import (
// StreamCallbacks defines callbacks for stream events
type StreamCallbacks struct {
OnSessionUpdate func(execution *AgentExecution, update agentctl.SessionUpdate)
OnPermission func(execution *AgentExecution, notification *agentctl.PermissionNotification)
OnGitStatus func(execution *AgentExecution, update *agentctl.GitStatusUpdate)
OnFileChange func(execution *AgentExecution, notification *agentctl.FileChangeNotification)
OnAgentEvent func(execution *AgentExecution, event agentctl.AgentEvent)
OnGitStatus func(execution *AgentExecution, update *agentctl.GitStatusUpdate)
OnFileChange func(execution *AgentExecution, notification *agentctl.FileChangeNotification)
}
// StreamManager manages WebSocket streams to agent executions
@@ -36,7 +35,6 @@ func NewStreamManager(log *logger.Logger, callbacks StreamCallbacks) *StreamMana
// If ready is non-nil, it will be closed when the updates stream is connected.
func (sm *StreamManager) ConnectAll(execution *AgentExecution, ready chan<- struct{}) {
go sm.connectUpdatesStream(execution, ready)
go sm.connectPermissionStream(execution)
go sm.connectGitStatusStream(execution)
go sm.connectFileChangesStream(execution)
}
@@ -74,9 +72,9 @@ func (sm *StreamManager) ReconnectAll(execution *AgentExecution) {
func (sm *StreamManager) connectUpdatesStream(execution *AgentExecution, ready chan<- struct{}) {
ctx := context.Background()
err := execution.agentctl.StreamUpdates(ctx, func(update agentctl.SessionUpdate) {
if sm.callbacks.OnSessionUpdate != nil {
sm.callbacks.OnSessionUpdate(execution, update)
err := execution.agentctl.StreamUpdates(ctx, func(event agentctl.AgentEvent) {
if sm.callbacks.OnAgentEvent != nil {
sm.callbacks.OnAgentEvent(execution, event)
}
})
@@ -94,28 +92,7 @@ func (sm *StreamManager) connectUpdatesStream(execution *AgentExecution, ready c
}
}
// connectPermissionStream handles the permission WebSocket stream
func (sm *StreamManager) connectPermissionStream(execution *AgentExecution) {
ctx := context.Background()
sm.logger.Debug("connecting to permission stream",
zap.String("instance_id", execution.ID),
zap.String("task_id", execution.TaskID))
err := execution.agentctl.StreamPermissions(ctx, func(notification *agentctl.PermissionNotification) {
sm.logger.Debug("received permission notification from stream",
zap.String("pending_id", notification.PendingID),
zap.String("title", notification.Title))
if sm.callbacks.OnPermission != nil {
sm.callbacks.OnPermission(execution, notification)
}
})
if err != nil {
sm.logger.Error("failed to connect to permission stream",
zap.String("instance_id", execution.ID),
zap.Error(err))
}
}
// connectGitStatusStream handles git status stream with retry logic
func (sm *StreamManager) connectGitStatusStream(execution *AgentExecution) {
+21 -166
View File
@@ -10,9 +10,19 @@ import (
"github.com/coder/acp-go-sdk"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"go.uber.org/zap"
)
// Re-export stream types for convenience.
type (
AgentEvent = streams.AgentEvent
PermissionNotification = streams.PermissionNotification
PermissionOption = streams.PermissionOption
PermissionRespondRequest = streams.PermissionRespondRequest
PermissionRespondResponse = streams.PermissionRespondResponse
)
// AgentInfo contains information about the connected agent.
type AgentInfo struct {
Name string `json:"name"`
@@ -237,70 +247,12 @@ func (c *Client) Prompt(ctx context.Context, text string) (*PromptResponse, erro
return &result, nil
}
// SessionUpdate represents a session update from the agent.
// This matches adapter.SessionUpdate that agentctl sends over WebSocket.
type SessionUpdate struct {
Type string `json:"type"`
SessionID string `json:"session_id,omitempty"`
OperationID string `json:"operation_id,omitempty"` // Turn/operation ID for cancellation
// Note: AgentEvent, PermissionNotification, PermissionOption, and
// PermissionRespondRequest are re-exported from streams package at the top of this file.
// Message fields
Text string `json:"text,omitempty"`
// Reasoning fields (for "reasoning" type)
ReasoningText string `json:"reasoning_text,omitempty"`
ReasoningSummary string `json:"reasoning_summary,omitempty"`
// Tool call fields
ToolCallID string `json:"tool_call_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolTitle string `json:"tool_title,omitempty"`
ToolStatus string `json:"tool_status,omitempty"`
ToolArgs map[string]interface{} `json:"tool_args,omitempty"`
ToolResult interface{} `json:"tool_result,omitempty"`
// Diff for file changes
Diff string `json:"diff,omitempty"`
// Error and raw data
Error string `json:"error,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
}
// PermissionOption represents a permission choice
type PermissionOption struct {
OptionID string `json:"option_id"`
Name string `json:"name"`
Kind string `json:"kind"` // allow_once, allow_always, reject_once, reject_always
}
// PermissionNotification is received when the agent requests permission
type PermissionNotification struct {
PendingID string `json:"pending_id"`
SessionID string `json:"session_id"`
ToolCallID string `json:"tool_call_id"`
Title string `json:"title"`
Options []PermissionOption `json:"options"`
ActionType string `json:"action_type,omitempty"` // command, file_write, file_read, network, mcp_tool, other
ActionDetails map[string]interface{} `json:"action_details,omitempty"` // Additional details about the action
CreatedAt time.Time `json:"created_at"`
}
// PermissionResponse is the user's response to a permission request
type PermissionRespondRequest struct {
PendingID string `json:"pending_id"`
OptionID string `json:"option_id,omitempty"`
Cancelled bool `json:"cancelled,omitempty"`
}
// PermissionRespondResponse from agentctl
type PermissionRespondResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// StreamUpdates opens a WebSocket connection for streaming session updates
func (c *Client) StreamUpdates(ctx context.Context, handler func(SessionUpdate)) error {
// StreamUpdates opens a WebSocket connection for streaming agent events.
// Events include message chunks, reasoning, tool calls, plan updates, and completion/error.
func (c *Client) StreamUpdates(ctx context.Context, handler func(AgentEvent)) error {
wsURL := "ws" + c.baseURL[4:] + "/api/v1/acp/stream"
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
@@ -336,99 +288,33 @@ func (c *Client) StreamUpdates(ctx context.Context, handler func(SessionUpdate))
return
}
var update SessionUpdate
if err := json.Unmarshal(message, &update); err != nil {
c.logger.Warn("failed to parse session update", zap.Error(err))
var event AgentEvent
if err := json.Unmarshal(message, &event); err != nil {
c.logger.Warn("failed to parse agent event", zap.Error(err))
continue
}
handler(update)
handler(event)
}
}()
return nil
}
// CloseUpdatesStream closes the updates stream connection
// CloseUpdatesStream closes the agent events stream connection.
func (c *Client) CloseUpdatesStream() {
c.CloseACPStream()
}
// CloseACPStream closes the ACP stream connection
func (c *Client) CloseACPStream() {
c.mu.Lock()
defer c.mu.Unlock()
if c.acpConn != nil {
if err := c.acpConn.Close(); err != nil {
c.logger.Debug("failed to close ACP stream", zap.Error(err))
c.logger.Debug("failed to close agent events stream", zap.Error(err))
}
c.acpConn = nil
}
}
// StreamPermissions opens a WebSocket connection for streaming permission requests
func (c *Client) StreamPermissions(ctx context.Context, handler func(*PermissionNotification)) error {
wsURL := "ws" + c.baseURL[4:] + "/api/v1/acp/permissions/stream"
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil)
if err != nil {
return fmt.Errorf("failed to connect to permission stream: %w", err)
}
c.mu.Lock()
c.permissionConn = conn
c.mu.Unlock()
c.logger.Info("connected to permission stream", zap.String("url", wsURL))
// Read messages in a goroutine
go func() {
defer func() {
c.mu.Lock()
c.permissionConn = nil
c.mu.Unlock()
if err := conn.Close(); err != nil {
c.logger.Debug("failed to close permissions websocket", zap.Error(err))
}
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
c.logger.Info("permission stream closed normally")
} else {
c.logger.Debug("permission stream error", zap.Error(err))
}
return
}
var notification PermissionNotification
if err := json.Unmarshal(message, &notification); err != nil {
c.logger.Warn("failed to parse permission notification", zap.Error(err))
continue
}
handler(&notification)
}
}()
return nil
}
// ClosePermissionStream closes the permission stream connection
func (c *Client) ClosePermissionStream() {
c.mu.Lock()
defer c.mu.Unlock()
if c.permissionConn != nil {
if err := c.permissionConn.Close(); err != nil {
c.logger.Debug("failed to close permission stream", zap.Error(err))
}
c.permissionConn = nil
}
}
// RespondToPermission sends a response to a permission request
func (c *Client) RespondToPermission(ctx context.Context, pendingID, optionID string, cancelled bool) error {
@@ -478,35 +364,4 @@ func (c *Client) RespondToPermission(ctx context.Context, pendingID, optionID st
return nil
}
// GetPendingPermissions retrieves all pending permission requests from agentctl
func (c *Client) GetPendingPermissions(ctx context.Context) ([]PermissionNotification, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/acp/permissions", nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
c.logger.Debug("failed to close permissions response body", zap.Error(err))
}
}()
respBody, err := readResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("get pending permissions failed with status %d: %s", resp.StatusCode, string(respBody))
}
var result []PermissionNotification
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse permissions response: %w", err)
}
return result, nil
}
@@ -24,7 +24,6 @@ type Client struct {
// WebSocket connections for streaming
acpConn *websocket.Conn
permissionConn *websocket.Conn
gitStatusConn *websocket.Conn
fileChangesConn *websocket.Conn
shellConn *websocket.Conn
@@ -251,7 +250,8 @@ func (c *Client) BaseURL() string {
return c.baseURL
}
// Re-export types from types package for convenience
// Re-export types from types package for convenience.
// These types are defined in the streams subpackage and re-exported through types.
type (
GitStatusUpdate = types.GitStatusUpdate
FileInfo = types.FileInfo
@@ -263,6 +263,9 @@ type (
FileContentRequest = types.FileContentRequest
FileContentResponse = types.FileContentResponse
FileChangeNotification = types.FileChangeNotification
ShellMessage = types.ShellMessage
ShellStatusResponse = types.ShellStatusResponse
ShellBufferResponse = types.ShellBufferResponse
)
// StreamGitStatus opens a WebSocket connection for streaming git status updates
@@ -453,23 +456,16 @@ func (c *Client) CloseFileChangesStream() {
}
}
// Close closes all connections (ACP, permissions, git status, file changes, shell)
// Close closes all connections
func (c *Client) Close() {
c.CloseACPStream()
c.ClosePermissionStream()
c.CloseUpdatesStream()
c.CloseGitStatusStream()
c.CloseFileChangesStream()
c.CloseShellStream()
}
// ShellStatusResponse from agentctl shell status endpoint
type ShellStatusResponse struct {
Running bool `json:"running"`
Pid int `json:"pid"`
Shell string `json:"shell"`
Cwd string `json:"cwd"`
StartedAt string `json:"started_at"`
}
// Note: ShellStatusResponse, ShellMessage, ShellBufferResponse are re-exported
// from streams package at the top of this file in the type block.
// ShellStatus gets the status of the embedded shell session
func (c *Client) ShellStatus(ctx context.Context) (*ShellStatusResponse, error) {
@@ -496,18 +492,6 @@ func (c *Client) ShellStatus(ctx context.Context) (*ShellStatusResponse, error)
return &result, nil
}
// ShellMessage represents a message to/from the shell
type ShellMessage struct {
Type string `json:"type"` // "input", "output", "ping", "pong", "exit"
Data string `json:"data,omitempty"`
Code int `json:"code,omitempty"` // For exit type
}
// ShellBufferResponse is the response from the shell buffer endpoint
type ShellBufferResponse struct {
Data string `json:"data"`
}
// ShellBuffer returns the buffered shell output
func (c *Client) ShellBuffer(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/shell/buffer", nil)
@@ -1,5 +1,5 @@
// Package agentctl provides a client for communicating with agentctl.
// This file contains the StandaloneCtl client for the multi-instance control API.
// This file contains the ControlClient for the agentctl control server API.
package client
import (
@@ -14,9 +14,10 @@ import (
"go.uber.org/zap"
)
// StandaloneCtl is a client for the agentctl control API in standalone mode.
// ControlClient is a client for the agentctl control server API.
// It manages creation and deletion of agent instances.
type StandaloneCtl struct {
// Used by both Docker and standalone runtimes.
type ControlClient struct {
baseURL string
httpClient *http.Client
logger *logger.Logger
@@ -50,25 +51,25 @@ type InstanceInfo struct {
CreatedAt time.Time `json:"created_at"`
}
// NewStandaloneCtl creates a new StandaloneCtl client.
func NewStandaloneCtl(host string, port int, log *logger.Logger) *StandaloneCtl {
return &StandaloneCtl{
// NewControlClient creates a new ControlClient for the agentctl control server.
func NewControlClient(host string, port int, log *logger.Logger) *ControlClient {
return &ControlClient{
baseURL: fmt.Sprintf("http://%s:%d", host, port),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
logger: log.WithFields(zap.String("component", "standalone-agentctl")),
logger: log.WithFields(zap.String("component", "agentctl-control")),
}
}
// Health checks if the standalone agentctl is healthy.
func (s *StandaloneCtl) Health(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL+"/health", nil)
// Health checks if the agentctl control server is healthy.
func (c *ControlClient) Health(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/health", nil)
if err != nil {
return err
}
resp, err := s.httpClient.Do(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
@@ -83,19 +84,19 @@ func (s *StandaloneCtl) Health(ctx context.Context) error {
}
// CreateInstance creates a new agent instance.
func (s *StandaloneCtl) CreateInstance(ctx context.Context, req *CreateInstanceRequest) (*CreateInstanceResponse, error) {
func (c *ControlClient) CreateInstance(ctx context.Context, req *CreateInstanceRequest) (*CreateInstanceResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", s.baseURL+"/api/v1/instances", bytes.NewReader(body))
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/instances", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(httpReq)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to create instance: %w", err)
}
@@ -116,7 +117,7 @@ func (s *StandaloneCtl) CreateInstance(ctx context.Context, req *CreateInstanceR
return nil, fmt.Errorf("failed to decode response: %w", err)
}
s.logger.Info("created agent instance",
c.logger.Info("created agent instance",
zap.String("instance_id", result.ID),
zap.Int("port", result.Port))
@@ -124,13 +125,13 @@ func (s *StandaloneCtl) CreateInstance(ctx context.Context, req *CreateInstanceR
}
// DeleteInstance stops and removes an agent instance.
func (s *StandaloneCtl) DeleteInstance(ctx context.Context, instanceID string) error {
req, err := http.NewRequestWithContext(ctx, "DELETE", s.baseURL+"/api/v1/instances/"+instanceID, nil)
func (c *ControlClient) DeleteInstance(ctx context.Context, instanceID string) error {
req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+"/api/v1/instances/"+instanceID, nil)
if err != nil {
return err
}
resp, err := s.httpClient.Do(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to delete instance: %w", err)
}
@@ -148,18 +149,18 @@ func (s *StandaloneCtl) DeleteInstance(ctx context.Context, instanceID string) e
return fmt.Errorf("failed to delete instance: %s (status %d)", errResp.Error, resp.StatusCode)
}
s.logger.Info("deleted agent instance", zap.String("instance_id", instanceID))
c.logger.Info("deleted agent instance", zap.String("instance_id", instanceID))
return nil
}
// GetInstance gets information about a specific instance.
func (s *StandaloneCtl) GetInstance(ctx context.Context, instanceID string) (*InstanceInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL+"/api/v1/instances/"+instanceID, nil)
func (c *ControlClient) GetInstance(ctx context.Context, instanceID string) (*InstanceInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/instances/"+instanceID, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient.Do(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get instance: %w", err)
}
@@ -180,13 +181,13 @@ func (s *StandaloneCtl) GetInstance(ctx context.Context, instanceID string) (*In
}
// ListInstances lists all running agent instances.
func (s *StandaloneCtl) ListInstances(ctx context.Context) ([]*InstanceInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL+"/api/v1/instances", nil)
func (c *ControlClient) ListInstances(ctx context.Context) ([]*InstanceInfo, error) {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/instances", nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient.Do(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to list instances: %w", err)
}
@@ -34,7 +34,7 @@ type ACPAdapter struct {
agentInfo *AgentInfo
// Update channel
updatesCh chan SessionUpdate
updatesCh chan AgentEvent
// Permission handler
permissionHandler PermissionHandler
@@ -52,7 +52,7 @@ func NewACPAdapter(stdin io.Writer, stdout io.Reader, cfg *Config, log *logger.L
logger: log.WithFields(zap.String("adapter", "acp")),
stdin: stdin,
stdout: stdout,
updatesCh: make(chan SessionUpdate, 100),
updatesCh: make(chan AgentEvent, 100),
}
}
@@ -62,7 +62,7 @@ func (a *ACPAdapter) Initialize(ctx context.Context) error {
a.logger.Info("initializing ACP adapter",
zap.String("workdir", a.cfg.WorkDir))
// Create ACP client with update handler that converts to SessionUpdate
// Create ACP client with update handler that converts to AgentEvent
a.acpClient = acpclient.NewClient(
acpclient.WithLogger(a.logger.Zap()),
acpclient.WithWorkspaceRoot(a.cfg.WorkDir),
@@ -190,8 +190,8 @@ func (a *ACPAdapter) Cancel(ctx context.Context) error {
})
}
// Updates returns the channel for session updates.
func (a *ACPAdapter) Updates() <-chan SessionUpdate {
// Updates returns the channel for agent events.
func (a *ACPAdapter) Updates() <-chan AgentEvent {
return a.updatesCh
}
@@ -244,20 +244,20 @@ func (a *ACPAdapter) GetACPConnection() *acp.ClientSideConnection {
return a.acpConn
}
// handleACPUpdate converts ACP SessionNotification to protocol-agnostic SessionUpdate.
// handleACPUpdate converts ACP SessionNotification to protocol-agnostic AgentEvent.
func (a *ACPAdapter) handleACPUpdate(n acp.SessionNotification) {
update := a.convertNotification(n)
if update != nil {
event := a.convertNotification(n)
if event != nil {
select {
case a.updatesCh <- *update:
case a.updatesCh <- *event:
default:
a.logger.Warn("updates channel full, dropping notification")
}
}
}
// convertNotification converts an ACP SessionNotification to a SessionUpdate.
func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *SessionUpdate {
// convertNotification converts an ACP SessionNotification to an AgentEvent.
func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *AgentEvent {
u := n.Update
// Log what update types we're receiving
@@ -271,8 +271,8 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *SessionUpda
switch {
case u.AgentMessageChunk != nil:
if u.AgentMessageChunk.Content.Text != nil {
return &SessionUpdate{
Type: UpdateTypeMessageChunk,
return &AgentEvent{
Type: EventTypeMessageChunk,
SessionID: string(n.SessionId),
Text: u.AgentMessageChunk.Content.Text.Text,
}
@@ -281,8 +281,8 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *SessionUpda
case u.AgentThoughtChunk != nil:
// Agent thinking/reasoning - map to the new reasoning type
if u.AgentThoughtChunk.Content.Text != nil {
return &SessionUpdate{
Type: UpdateTypeReasoning,
return &AgentEvent{
Type: EventTypeReasoning,
SessionID: string(n.SessionId),
ReasoningText: u.AgentThoughtChunk.Content.Text.Text,
}
@@ -324,8 +324,8 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *SessionUpda
status = "running"
}
return &SessionUpdate{
Type: UpdateTypeToolCall,
return &AgentEvent{
Type: EventTypeToolCall,
SessionID: string(n.SessionId),
ToolCallID: string(u.ToolCall.ToolCallId),
ToolName: string(u.ToolCall.Kind), // Kind is effectively the tool name
@@ -343,8 +343,8 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *SessionUpda
if status == "completed" {
status = "complete"
}
return &SessionUpdate{
Type: UpdateTypeToolUpdate,
return &AgentEvent{
Type: EventTypeToolUpdate,
SessionID: string(n.SessionId),
ToolCallID: string(u.ToolCallUpdate.ToolCallId),
ToolStatus: status,
@@ -359,8 +359,8 @@ func (a *ACPAdapter) convertNotification(n acp.SessionNotification) *SessionUpda
Priority: string(e.Priority),
}
}
return &SessionUpdate{
Type: UpdateTypePlan,
return &AgentEvent{
Type: EventTypePlan,
SessionID: string(n.SessionId),
PlanEntries: entries,
}
@@ -6,6 +6,7 @@ import (
"context"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
)
// Re-export permission types from the shared types package for convenience.
@@ -14,71 +15,27 @@ import (
type (
PermissionRequest = types.PermissionRequest
PermissionResponse = types.PermissionResponse
PermissionOption = types.PermissionOption
PermissionOption = streams.PermissionOption
PermissionHandler = types.PermissionHandler
)
// SessionUpdate type constants
const (
UpdateTypeMessageChunk = "message_chunk" // Agent text streaming
UpdateTypeReasoning = "reasoning" // Chain-of-thought/thinking
UpdateTypeToolCall = "tool_call" // Tool invocation started
UpdateTypeToolUpdate = "tool_update" // Tool status update
UpdateTypePlan = "plan" // Agent plan updates
UpdateTypeComplete = "complete" // Turn/session complete
UpdateTypeError = "error" // Error occurred
// Re-export stream types for convenience.
type (
AgentEvent = streams.AgentEvent
PlanEntry = streams.PlanEntry
)
// SessionUpdate is a protocol-agnostic update from the agent.
// All protocol adapters normalize their updates to this format.
type SessionUpdate struct {
// Type identifies the update type. Use UpdateType* constants.
Type string `json:"type"`
// SessionID is the current session identifier
SessionID string `json:"session_id,omitempty"`
// OperationID identifies the current in-flight operation (turn, prompt, etc.)
// Used to target specific operations for cancellation or status updates.
// For Codex this is the turn ID, for other protocols it may be empty.
OperationID string `json:"operation_id,omitempty"`
// Message fields (for "message_chunk" type)
Text string `json:"text,omitempty"`
// Reasoning fields (for "reasoning" type)
// Used for chain-of-thought, thinking traces, etc.
ReasoningText string `json:"reasoning_text,omitempty"` // Full reasoning content
ReasoningSummary string `json:"reasoning_summary,omitempty"` // Summarized version (if available)
// Tool call fields (for "tool_call" and "tool_update" types)
ToolCallID string `json:"tool_call_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolTitle string `json:"tool_title,omitempty"`
ToolStatus string `json:"tool_status,omitempty"` // "started", "running", "completed", "error"
ToolArgs map[string]interface{} `json:"tool_args,omitempty"`
ToolResult interface{} `json:"tool_result,omitempty"`
// Diff contains unified diff content for file changes.
// Populated when tools modify files, providing the aggregated diff.
Diff string `json:"diff,omitempty"`
// Plan fields (for "plan" type)
PlanEntries []PlanEntry `json:"plan_entries,omitempty"`
// Error fields (for "error" type)
Error string `json:"error,omitempty"`
// Raw data for protocol-specific extensions
Data map[string]interface{} `json:"data,omitempty"`
}
// PlanEntry represents an entry in the agent's execution plan
type PlanEntry struct {
Description string `json:"description,omitempty"` // Content/description of the task
Status string `json:"status,omitempty"` // "pending", "in_progress", "completed", "failed"
Priority string `json:"priority,omitempty"` // Relative importance
}
// Re-export agent event type constants from streams package.
const (
EventTypeMessageChunk = streams.EventTypeMessageChunk
EventTypeReasoning = streams.EventTypeReasoning
EventTypeToolCall = streams.EventTypeToolCall
EventTypeToolUpdate = streams.EventTypeToolUpdate
EventTypePlan = streams.EventTypePlan
EventTypeComplete = streams.EventTypeComplete
EventTypeError = streams.EventTypeError
EventTypePermissionRequest = streams.EventTypePermissionRequest
)
// AgentInfo contains information about the connected agent.
type AgentInfo struct {
@@ -88,7 +45,7 @@ type AgentInfo struct {
// AgentAdapter defines the interface for protocol adapters.
// Each adapter translates a specific protocol (ACP, REST, MCP, etc.) into the
// normalized SessionUpdate format that agentctl exposes via its HTTP API.
// normalized AgentEvent format that agentctl exposes via its HTTP API.
type AgentAdapter interface {
// Initialize establishes the connection with the agent and exchanges capabilities.
// For subprocess-based agents (ACP), this sends the initialize request.
@@ -112,9 +69,9 @@ type AgentAdapter interface {
// Cancel cancels the current operation.
Cancel(ctx context.Context) error
// Updates returns a channel that receives session updates.
// Updates returns a channel that receives agent events.
// The channel is closed when the adapter is closed.
Updates() <-chan SessionUpdate
Updates() <-chan AgentEvent
// GetSessionID returns the current session ID.
GetSessionID() string
@@ -6,7 +6,7 @@ import (
)
func TestSessionUpdate_Fields(t *testing.T) {
update := SessionUpdate{
update := AgentEvent{
Type: "tool_call",
SessionID: "session-123",
Text: "test message",
@@ -56,7 +56,7 @@ func TestSessionUpdate_Fields(t *testing.T) {
}
func TestSessionUpdate_JSONSerialization(t *testing.T) {
update := SessionUpdate{
update := AgentEvent{
Type: "message_chunk",
SessionID: "session-abc",
Text: "Hello, world!",
@@ -68,7 +68,7 @@ func TestSessionUpdate_JSONSerialization(t *testing.T) {
t.Fatalf("failed to marshal SessionUpdate: %v", err)
}
var decoded SessionUpdate
var decoded AgentEvent
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal SessionUpdate: %v", err)
}
@@ -86,7 +86,7 @@ func TestSessionUpdate_JSONSerialization(t *testing.T) {
func TestSessionUpdate_JSONOmitEmpty(t *testing.T) {
// Test that omitempty fields are not included when empty
update := SessionUpdate{
update := AgentEvent{
Type: "error",
}
@@ -257,7 +257,7 @@ func TestSessionUpdate_UpdateTypes(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
update := SessionUpdate{Type: tc.updateType}
update := AgentEvent{Type: tc.updateType}
if update.Type != tc.updateType {
t.Errorf("expected Type %q, got %q", tc.updateType, update.Type)
}
@@ -288,7 +288,7 @@ func TestPlanEntry_StatusValues(t *testing.T) {
func TestSessionUpdate_WithComplexToolArgs(t *testing.T) {
// Test with complex nested ToolArgs
update := SessionUpdate{
update := AgentEvent{
Type: "tool_call",
ToolCallID: "call-123",
ToolName: "file_edit",
@@ -311,7 +311,7 @@ func TestSessionUpdate_WithComplexToolArgs(t *testing.T) {
t.Fatalf("failed to marshal: %v", err)
}
var decoded SessionUpdate
var decoded AgentEvent
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
@@ -328,4 +328,3 @@ func TestSessionUpdate_WithComplexToolArgs(t *testing.T) {
t.Errorf("expected 2 locations, got %d", len(locations))
}
}
@@ -39,7 +39,7 @@ type CodexAdapter struct {
agentInfo *AgentInfo
// Update channel
updatesCh chan SessionUpdate
updatesCh chan AgentEvent
// Permission handler
permissionHandler PermissionHandler
@@ -65,7 +65,7 @@ func NewCodexAdapter(stdin io.Writer, stdout io.Reader, cfg *Config, log *logger
stdout: stdout,
ctx: ctx,
cancel: cancel,
updatesCh: make(chan SessionUpdate, 100),
updatesCh: make(chan AgentEvent, 100),
}
}
@@ -292,8 +292,8 @@ func (a *CodexAdapter) Cancel(ctx context.Context) error {
return err
}
// Updates returns the channel for session updates.
func (a *CodexAdapter) Updates() <-chan SessionUpdate {
// Updates returns the channel for agent events.
func (a *CodexAdapter) Updates() <-chan AgentEvent {
return a.updatesCh
}
@@ -346,8 +346,8 @@ func (a *CodexAdapter) Close() error {
return nil
}
// sendUpdate safely sends an update to the updates channel.
func (a *CodexAdapter) sendUpdate(update SessionUpdate) {
// sendUpdate safely sends an event to the updates channel.
func (a *CodexAdapter) sendUpdate(update AgentEvent) {
select {
case a.updatesCh <- update:
default:
@@ -355,7 +355,7 @@ func (a *CodexAdapter) sendUpdate(update SessionUpdate) {
}
}
// handleNotification processes Codex notifications and emits SessionUpdates.
// handleNotification processes Codex notifications and emits AgentEvents.
func (a *CodexAdapter) handleNotification(method string, params json.RawMessage) {
a.mu.RLock()
threadID := a.threadID
@@ -373,8 +373,8 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
a.mu.Lock()
a.messageBuffer += p.Delta
a.mu.Unlock()
a.sendUpdate(SessionUpdate{
Type: UpdateTypeMessageChunk,
a.sendUpdate(AgentEvent{
Type: EventTypeMessageChunk,
SessionID: threadID,
OperationID: turnID,
Text: p.Delta,
@@ -389,8 +389,8 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
a.mu.Lock()
a.reasoningBuffer += p.Delta
a.mu.Unlock()
a.sendUpdate(SessionUpdate{
Type: UpdateTypeReasoning,
a.sendUpdate(AgentEvent{
Type: EventTypeReasoning,
SessionID: threadID,
OperationID: turnID,
ReasoningText: p.Delta,
@@ -405,8 +405,8 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
a.mu.Lock()
a.summaryBuffer += p.Delta
a.mu.Unlock()
a.sendUpdate(SessionUpdate{
Type: UpdateTypeReasoning,
a.sendUpdate(AgentEvent{
Type: EventTypeReasoning,
SessionID: threadID,
OperationID: turnID,
ReasoningSummary: p.Delta,
@@ -418,13 +418,13 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
a.logger.Warn("failed to parse turn completed", zap.Error(err))
return
}
update := SessionUpdate{
Type: UpdateTypeComplete,
update := AgentEvent{
Type: EventTypeComplete,
SessionID: threadID,
OperationID: p.TurnID,
}
if !p.Success && p.Error != "" {
update.Type = UpdateTypeError
update.Type = EventTypeError
update.Error = p.Error
}
a.sendUpdate(update)
@@ -435,8 +435,8 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
a.logger.Warn("failed to parse turn diff updated", zap.Error(err))
return
}
a.sendUpdate(SessionUpdate{
Type: UpdateTypeMessageChunk,
a.sendUpdate(AgentEvent{
Type: EventTypeMessageChunk,
SessionID: threadID,
OperationID: p.TurnID,
Diff: p.Diff,
@@ -455,8 +455,8 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
Status: e.Status,
}
}
a.sendUpdate(SessionUpdate{
Type: UpdateTypePlan,
a.sendUpdate(AgentEvent{
Type: EventTypePlan,
SessionID: threadID,
OperationID: p.TurnID,
PlanEntries: entries,
@@ -468,8 +468,8 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
a.logger.Warn("failed to parse error notification", zap.Error(err))
return
}
a.sendUpdate(SessionUpdate{
Type: UpdateTypeError,
a.sendUpdate(AgentEvent{
Type: EventTypeError,
SessionID: threadID,
Error: p.Message,
})
@@ -487,8 +487,8 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
// Item types: "userMessage", "agentMessage", "commandExecution", "fileChange", "reasoning"
switch p.Item.Type {
case "commandExecution":
a.sendUpdate(SessionUpdate{
Type: UpdateTypeToolCall,
a.sendUpdate(AgentEvent{
Type: EventTypeToolCall,
SessionID: threadID,
OperationID: turnID,
ToolCallID: p.Item.ID,
@@ -509,8 +509,8 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
title += fmt.Sprintf(" (+%d more)", len(p.Item.Changes)-1)
}
}
a.sendUpdate(SessionUpdate{
Type: UpdateTypeToolCall,
a.sendUpdate(AgentEvent{
Type: EventTypeToolCall,
SessionID: threadID,
OperationID: turnID,
ToolCallID: p.Item.ID,
@@ -535,8 +535,8 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
if p.Item.Status == "failed" {
status = "error"
}
update := SessionUpdate{
Type: UpdateTypeToolUpdate,
update := AgentEvent{
Type: EventTypeToolUpdate,
SessionID: threadID,
OperationID: turnID,
ToolCallID: p.Item.ID,
@@ -567,8 +567,8 @@ func (a *CodexAdapter) handleNotification(method string, params json.RawMessage)
a.logger.Warn("failed to parse command output delta", zap.Error(err))
return
}
a.sendUpdate(SessionUpdate{
Type: UpdateTypeToolUpdate,
a.sendUpdate(AgentEvent{
Type: EventTypeToolUpdate,
SessionID: threadID,
OperationID: turnID,
ToolCallID: p.ItemID,
@@ -319,82 +319,3 @@ func (s *Server) handlePermissionRespond(c *gin.Context) {
})
}
// PendingPermissionResponse represents a pending permission for the API
type PendingPermissionResponse struct {
PendingID string `json:"pending_id"`
SessionID string `json:"session_id"`
ToolCallID string `json:"tool_call_id"`
Title string `json:"title"`
Options []PermissionOptionJSON `json:"options"`
CreatedAt string `json:"created_at"`
}
// PermissionOptionJSON is the JSON representation of a permission option
type PermissionOptionJSON struct {
OptionID string `json:"option_id"`
Name string `json:"name"`
Kind string `json:"kind"`
}
func (s *Server) handleGetPendingPermissions(c *gin.Context) {
pending := s.procMgr.GetPendingPermissions()
result := make([]PendingPermissionResponse, 0, len(pending))
for _, p := range pending {
options := make([]PermissionOptionJSON, len(p.Request.Options))
for i, opt := range p.Request.Options {
options[i] = PermissionOptionJSON{
OptionID: opt.OptionID,
Name: opt.Name,
Kind: opt.Kind,
}
}
result = append(result, PendingPermissionResponse{
PendingID: p.ID,
SessionID: p.Request.SessionID,
ToolCallID: p.Request.ToolCallID,
Title: p.Request.Title,
Options: options,
CreatedAt: p.CreatedAt.Format(time.RFC3339),
})
}
c.JSON(http.StatusOK, result)
}
// handlePermissionStreamWS streams permission request notifications via WebSocket
func (s *Server) handlePermissionStreamWS(c *gin.Context) {
conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
s.logger.Error("WebSocket upgrade failed", zap.Error(err))
return
}
defer func() {
if err := conn.Close(); err != nil {
s.logger.Debug("failed to close permission websocket", zap.Error(err))
}
}()
s.logger.Info("Permission stream WebSocket connected")
// Get the permission requests channel
permissionCh := s.procMgr.GetPermissionRequests()
// Stream permission notifications to WebSocket
for notification := range permissionCh {
s.logger.Info("streaming permission notification to backend",
zap.String("pending_id", notification.PendingID),
zap.String("title", notification.Title))
data, err := json.Marshal(notification)
if err != nil {
s.logger.Error("failed to marshal permission notification", zap.Error(err))
continue
}
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
s.logger.Debug("WebSocket write error", zap.Error(err))
return
}
}
}
@@ -72,8 +72,6 @@ func (s *Server) setupRoutes() {
api.GET("/acp/stream", s.handleACPStreamWS)
// Permission request handling
api.GET("/acp/permissions", s.handleGetPendingPermissions)
api.GET("/acp/permissions/stream", s.handlePermissionStreamWS)
api.POST("/acp/permissions/respond", s.handlePermissionRespond)
// Workspace monitoring (git status, files)
@@ -15,6 +15,7 @@ import (
"github.com/kandev/kandev/internal/agentctl/server/adapter"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/shell"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/pkg/agent"
"go.uber.org/zap"
@@ -80,11 +81,8 @@ type Manager struct {
// Protocol adapter for agent communication
adapter adapter.AgentAdapter
// Session update notifications (protocol-agnostic)
updatesCh chan adapter.SessionUpdate
// Permission request notifications (sent to backend)
permissionCh chan *PermissionNotification
// Agent event notifications (protocol-agnostic)
updatesCh chan adapter.AgentEvent
// Pending permission requests waiting for user response
pendingPermissions map[string]*PendingPermission
@@ -104,8 +102,7 @@ func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager {
cfg: cfg,
logger: log.WithFields(zap.String("component", "process-manager")),
workspaceTracker: NewWorkspaceTracker(cfg.WorkDir, log),
updatesCh: make(chan adapter.SessionUpdate, 100),
permissionCh: make(chan *PermissionNotification, 10),
updatesCh: make(chan adapter.AgentEvent, 100),
pendingPermissions: make(map[string]*PendingPermission),
}
m.status.Store(StatusStopped)
@@ -344,8 +341,8 @@ func (m *Manager) forwardUpdates() {
}
}
// GetUpdates returns the channel for session update notifications
func (m *Manager) GetUpdates() <-chan adapter.SessionUpdate {
// GetUpdates returns the channel for agent event notifications
func (m *Manager) GetUpdates() <-chan adapter.AgentEvent {
return m.updatesCh
}
@@ -586,38 +583,39 @@ func (m *Manager) autoApprovePermission(req *adapter.PermissionRequest) (*adapte
}, nil
}
// sendPermissionNotification sends a permission request notification to the backend
// sendPermissionNotification sends a permission request notification through the updates channel
func (m *Manager) sendPermissionNotification(pending *PendingPermission) {
notification := &PermissionNotification{
PendingID: pending.ID,
SessionID: pending.Request.SessionID,
ToolCallID: pending.Request.ToolCallID,
Title: pending.Request.Title,
Options: pending.Request.Options,
ActionType: pending.Request.ActionType,
ActionDetails: pending.Request.ActionDetails,
CreatedAt: pending.CreatedAt,
// Convert options to streams.PermissionOption (types.PermissionOption is an alias)
options := make([]streams.PermissionOption, len(pending.Request.Options))
for i, opt := range pending.Request.Options {
options[i] = streams.PermissionOption(opt)
}
m.logger.Info("sending permission notification to backend",
event := adapter.AgentEvent{
Type: adapter.EventTypePermissionRequest,
SessionID: pending.Request.SessionID,
ToolCallID: pending.Request.ToolCallID,
PendingID: pending.ID,
PermissionTitle: pending.Request.Title,
PermissionOptions: options,
ActionType: pending.Request.ActionType,
ActionDetails: pending.Request.ActionDetails,
}
m.logger.Info("sending permission notification via updates channel",
zap.String("pending_id", pending.ID),
zap.String("title", pending.Request.Title),
zap.String("action_type", pending.Request.ActionType))
select {
case m.permissionCh <- notification:
case m.updatesCh <- event:
// Sent successfully
default:
m.logger.Warn("permission channel full, dropping notification",
m.logger.Warn("updates channel full, dropping permission notification",
zap.String("pending_id", pending.ID))
}
}
// GetPermissionRequests returns the channel for permission request notifications
func (m *Manager) GetPermissionRequests() <-chan *PermissionNotification {
return m.permissionCh
}
// RespondToPermission responds to a pending permission request
func (m *Manager) RespondToPermission(pendingID string, optionID string, cancelled bool) error {
m.permissionMu.RLock()
@@ -645,17 +643,7 @@ func (m *Manager) RespondToPermission(pendingID string, optionID string, cancell
}
}
// GetPendingPermissions returns all pending permission requests
func (m *Manager) GetPendingPermissions() []*PendingPermission {
m.permissionMu.RLock()
defer m.permissionMu.RUnlock()
result := make([]*PendingPermission, 0, len(m.pendingPermissions))
for _, p := range m.pendingPermissions {
result = append(result, p)
}
return result
}
// Shell returns the embedded shell session, or nil if not available
func (m *Manager) Shell() *shell.Session {
@@ -0,0 +1,129 @@
package streams
// AgentEvent type constants define the types of events streamed from the agent.
const (
// EventTypeMessageChunk indicates streaming text content from the agent.
EventTypeMessageChunk = "message_chunk"
// EventTypeReasoning indicates chain-of-thought or thinking content.
EventTypeReasoning = "reasoning"
// EventTypeToolCall indicates a tool invocation has started.
EventTypeToolCall = "tool_call"
// EventTypeToolUpdate indicates a tool status update (running, completed, etc.).
EventTypeToolUpdate = "tool_update"
// EventTypePlan indicates agent plan/task list updates.
EventTypePlan = "plan"
// EventTypeComplete indicates the turn or session has completed.
EventTypeComplete = "complete"
// EventTypeError indicates an error occurred.
EventTypeError = "error"
// EventTypePermissionRequest indicates the agent is requesting permission.
EventTypePermissionRequest = "permission_request"
)
// AgentEvent is the message type streamed from the agent process.
// This represents protocol-agnostic events from the agent, normalized from
// various underlying protocols (ACP, Codex, Claude Code, etc.).
//
// Stream endpoint: ws://.../api/v1/agent/events
type AgentEvent struct {
// Type identifies the event type. Use EventType* constants:
// "message_chunk", "reasoning", "tool_call", "tool_update", "plan", "complete", "error"
Type string `json:"type"`
// SessionID is the current session identifier.
SessionID string `json:"session_id,omitempty"`
// OperationID identifies the current in-flight operation (turn, prompt, etc.).
// Used to target specific operations for cancellation or status updates.
// For Codex this is the turn ID, for other protocols it may be empty.
OperationID string `json:"operation_id,omitempty"`
// --- Message fields (for "message_chunk" type) ---
// Text contains streaming text content from the agent.
Text string `json:"text,omitempty"`
// --- Reasoning fields (for "reasoning" type) ---
// ReasoningText contains full reasoning/chain-of-thought content.
ReasoningText string `json:"reasoning_text,omitempty"`
// ReasoningSummary contains a summarized version of reasoning (if available).
ReasoningSummary string `json:"reasoning_summary,omitempty"`
// --- Tool call fields (for "tool_call" and "tool_update" types) ---
// ToolCallID uniquely identifies the tool invocation.
ToolCallID string `json:"tool_call_id,omitempty"`
// ToolName is the name of the tool being invoked.
ToolName string `json:"tool_name,omitempty"`
// ToolTitle is a human-readable title for the tool call.
ToolTitle string `json:"tool_title,omitempty"`
// ToolStatus indicates the current status: "started", "running", "completed", "error".
ToolStatus string `json:"tool_status,omitempty"`
// ToolArgs contains the arguments passed to the tool.
ToolArgs map[string]interface{} `json:"tool_args,omitempty"`
// ToolResult contains the result from the tool execution.
ToolResult interface{} `json:"tool_result,omitempty"`
// Diff contains unified diff content for file changes.
// Populated when tools modify files, providing the aggregated diff.
Diff string `json:"diff,omitempty"`
// --- Plan fields (for "plan" type) ---
// PlanEntries contains the agent's execution plan/task list.
PlanEntries []PlanEntry `json:"plan_entries,omitempty"`
// --- Error fields (for "error" type) ---
// Error contains error message when Type is "error".
Error string `json:"error,omitempty"`
// --- Permission request fields (for "permission_request" type) ---
// PendingID uniquely identifies this pending permission request.
PendingID string `json:"pending_id,omitempty"`
// PermissionTitle is a human-readable description of the action requiring permission.
PermissionTitle string `json:"permission_title,omitempty"`
// PermissionOptions contains the available permission choices.
PermissionOptions []PermissionOption `json:"permission_options,omitempty"`
// ActionType categorizes the action requiring approval.
// Use ActionType* constants: "command", "file_write", "file_read", "network", "mcp_tool", "other".
ActionType string `json:"action_type,omitempty"`
// ActionDetails contains structured details about the action.
ActionDetails map[string]interface{} `json:"action_details,omitempty"`
// --- Extension fields ---
// Data contains raw protocol-specific extensions.
Data map[string]interface{} `json:"data,omitempty"`
}
// PlanEntry represents an entry in the agent's execution plan.
type PlanEntry struct {
// Description is the content/description of the task.
Description string `json:"description,omitempty"`
// Status indicates task status: "pending", "in_progress", "completed", "failed".
Status string `json:"status,omitempty"`
// Priority indicates relative importance.
Priority string `json:"priority,omitempty"`
}
@@ -0,0 +1,51 @@
// Package streams defines the protocol message types for agentctl WebSocket streams.
//
// The agentctl client produces messages to the following channels/streams:
//
// # Agent Events Stream (/api/v1/agent/events)
//
// Streams real-time events from the agent process including message chunks,
// reasoning/thinking content, tool invocations, plan updates, and completion
// or error notifications. This stream is protocol-agnostic and works with
// any agent backend (ACP, Codex, Claude Code, etc.).
//
// Message type: AgentEvent
//
// Event types (use EventType* constants):
// - message_chunk: Streaming text content from the agent
// - reasoning: Chain-of-thought or thinking content
// - tool_call: A tool invocation has started
// - tool_update: Tool status update (running, completed, error)
// - plan: Agent plan/task list updates
// - complete: The turn or operation has completed
// - error: An error occurred
//
// # Permission Stream (/api/v1/acp/permissions/stream)
//
// Streams permission requests from the agent when it needs approval for
// actions like file writes, shell commands, or network access.
//
// Message type: PermissionNotification
//
// # Git Status Stream (/api/v1/workspace/git-status/stream)
//
// Streams git status updates when the workspace state changes.
//
// Message type: GitStatusUpdate
//
// # File Changes Stream (/api/v1/workspace/file-changes/stream)
//
// Streams file system change notifications when files are created,
// modified, deleted, or renamed in the workspace.
//
// Message type: FileChangeNotification
//
// # Shell Stream (/api/v1/shell/stream)
//
// Bidirectional WebSocket for interactive shell I/O.
//
// Message type: ShellMessage (input/output/ping/pong/exit)
//
// All streams use JSON-encoded messages over WebSocket connections.
package streams
@@ -0,0 +1,130 @@
package streams
import "time"
// File change operation constants.
const (
// FileOpCreate indicates a file was created.
FileOpCreate = "create"
// FileOpWrite indicates a file was written/modified.
FileOpWrite = "write"
// FileOpRemove indicates a file was removed.
FileOpRemove = "remove"
// FileOpRename indicates a file was renamed.
FileOpRename = "rename"
// FileOpChmod indicates file permissions changed.
FileOpChmod = "chmod"
// FileOpRefresh indicates a refresh/rescan of the file.
FileOpRefresh = "refresh"
)
// FileChangeNotification is the message type streamed via the file changes stream.
// Represents a filesystem change notification.
//
// Stream endpoint: ws://.../api/v1/workspace/file-changes/stream
type FileChangeNotification struct {
// Timestamp is when the change was detected.
Timestamp time.Time `json:"timestamp"`
// Path is the file path relative to workspace root.
Path string `json:"path"`
// Operation indicates the type of change. Use FileOp* constants:
// "create", "write", "remove", "rename", "chmod", "refresh".
Operation string `json:"operation"`
}
// FileListUpdate represents a file listing update.
// Used for initial sync or full refresh of workspace files.
type FileListUpdate struct {
// Timestamp is when the listing was captured.
Timestamp time.Time `json:"timestamp"`
// Files contains the list of files in the workspace.
Files []FileEntry `json:"files"`
}
// FileEntry represents a file in the workspace.
type FileEntry struct {
// Path is the file path relative to workspace root.
Path string `json:"path"`
// IsDir indicates if this is a directory.
IsDir bool `json:"is_dir"`
// Size is the file size in bytes (for files only).
Size int64 `json:"size,omitempty"`
}
// FileTreeNode represents a node in the file tree.
type FileTreeNode struct {
// Name is the file or directory name.
Name string `json:"name"`
// Path is the full path relative to workspace root.
Path string `json:"path"`
// IsDir indicates if this is a directory.
IsDir bool `json:"is_dir"`
// Size is the file size in bytes (for files only).
Size int64 `json:"size,omitempty"`
// Children contains child nodes (for directories only).
Children []*FileTreeNode `json:"children,omitempty"`
}
// FileTreeRequest represents a request for file tree.
//
// HTTP endpoint: GET /api/v1/workspace/tree
type FileTreeRequest struct {
// Path is the path to get tree for (relative to workspace root).
Path string `json:"path"`
// Depth is the depth to traverse (0 = unlimited, 1 = immediate children only).
Depth int `json:"depth"`
}
// FileTreeResponse represents a response with file tree.
type FileTreeResponse struct {
// RequestID identifies the request.
RequestID string `json:"request_id"`
// Root is the root node of the file tree.
Root *FileTreeNode `json:"root"`
// Error contains error message if the request failed.
Error string `json:"error,omitempty"`
}
// FileContentRequest represents a request for file content.
//
// HTTP endpoint: GET /api/v1/workspace/file/content
type FileContentRequest struct {
// Path is the file path (relative to workspace root).
Path string `json:"path"`
}
// FileContentResponse represents a response with file content.
type FileContentResponse struct {
// RequestID identifies the request.
RequestID string `json:"request_id"`
// Path is the file path.
Path string `json:"path"`
// Content is the file content.
Content string `json:"content"`
// Size is the file size in bytes.
Size int64 `json:"size"`
// Error contains error message if the request failed.
Error string `json:"error,omitempty"`
}
@@ -0,0 +1,64 @@
package streams
import "time"
// GitStatusUpdate is the message type streamed via the git status stream.
// Represents the current git state of the workspace.
//
// Stream endpoint: ws://.../api/v1/workspace/git-status/stream
type GitStatusUpdate struct {
// Timestamp is when this status was captured.
Timestamp time.Time `json:"timestamp"`
// Modified contains paths of modified files.
Modified []string `json:"modified"`
// Added contains paths of added/staged files.
Added []string `json:"added"`
// Deleted contains paths of deleted files.
Deleted []string `json:"deleted"`
// Untracked contains paths of untracked files.
Untracked []string `json:"untracked"`
// Renamed contains paths of renamed files.
Renamed []string `json:"renamed"`
// Ahead is the number of commits ahead of the remote branch.
Ahead int `json:"ahead"`
// Behind is the number of commits behind the remote branch.
Behind int `json:"behind"`
// Branch is the current local branch name.
Branch string `json:"branch"`
// RemoteBranch is the tracked remote branch (e.g., "origin/main").
RemoteBranch string `json:"remote_branch,omitempty"`
// Files contains detailed information about each changed file.
Files map[string]FileInfo `json:"files,omitempty"`
}
// FileInfo represents detailed information about a file's git status.
type FileInfo struct {
// Path is the file path relative to workspace root.
Path string `json:"path"`
// Status indicates the file status: "modified", "added", "deleted", "untracked", "renamed".
Status string `json:"status"`
// Additions is the number of added lines.
Additions int `json:"additions,omitempty"`
// Deletions is the number of deleted lines.
Deletions int `json:"deletions,omitempty"`
// OldPath is the original path for renamed files.
OldPath string `json:"old_path,omitempty"`
// Diff contains the unified diff content for this file.
Diff string `json:"diff,omitempty"`
}
@@ -0,0 +1,103 @@
package streams
import "time"
// Permission action types categorize the kind of action requiring approval.
const (
// ActionTypeCommand indicates shell command execution.
ActionTypeCommand = "command"
// ActionTypeFileWrite indicates file modification or creation.
ActionTypeFileWrite = "file_write"
// ActionTypeFileRead indicates file read (for sensitive files).
ActionTypeFileRead = "file_read"
// ActionTypeNetwork indicates network access.
ActionTypeNetwork = "network"
// ActionTypeMCPTool indicates MCP tool invocation.
ActionTypeMCPTool = "mcp_tool"
// ActionTypeOther indicates other/unknown action type.
ActionTypeOther = "other"
)
// PermissionNotification is the message type streamed via the permissions stream.
// Received when the agent requests permission for an action.
//
// Stream endpoint: ws://.../api/v1/acp/permissions/stream
type PermissionNotification struct {
// PendingID uniquely identifies this pending permission request.
PendingID string `json:"pending_id"`
// SessionID is the session making the request.
SessionID string `json:"session_id"`
// ToolCallID is the tool call that triggered this permission request.
ToolCallID string `json:"tool_call_id"`
// Title is a human-readable description of the action.
Title string `json:"title"`
// Options contains the available permission choices.
Options []PermissionOption `json:"options"`
// ActionType categorizes the action requiring approval.
// Use ActionType* constants: "command", "file_write", "file_read", "network", "mcp_tool", "other".
ActionType string `json:"action_type,omitempty"`
// ActionDetails contains structured details about the action.
// For commands: {"command": ["ls", "-la"], "cwd": "/path"}
// For files: {"path": "/file.go", "diff": "..."}
// For MCP tools: {"server": "...", "tool": "...", "arguments": {...}}
ActionDetails map[string]interface{} `json:"action_details,omitempty"`
// CreatedAt is when the permission request was created.
CreatedAt time.Time `json:"created_at"`
}
// PermissionOption represents a permission choice presented to the user.
type PermissionOption struct {
// OptionID uniquely identifies this option.
OptionID string `json:"option_id"`
// Name is a human-readable name for the option.
Name string `json:"name"`
// Kind indicates the type of permission: "allow_once", "allow_always",
// "reject_once", "reject_always".
Kind string `json:"kind"`
// Metadata contains protocol-specific option data.
// For Codex: {"for_session": true} for session-wide approvals.
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// PermissionRespondRequest is sent to respond to a permission request.
//
// HTTP endpoint: POST /api/v1/acp/permissions/respond
type PermissionRespondRequest struct {
// PendingID is the ID of the permission request to respond to.
PendingID string `json:"pending_id"`
// OptionID is the selected option ID.
OptionID string `json:"option_id,omitempty"`
// Cancelled indicates if the request was cancelled.
Cancelled bool `json:"cancelled,omitempty"`
// ResponseMetadata contains protocol-specific response data.
// For Codex: {"accept_settings": {"for_session": true}}.
ResponseMetadata map[string]interface{} `json:"response_metadata,omitempty"`
}
// PermissionRespondResponse is the response from the permission respond endpoint.
type PermissionRespondResponse struct {
// Success indicates if the response was accepted.
Success bool `json:"success"`
// Error contains error message if Success is false.
Error string `json:"error,omitempty"`
}
@@ -0,0 +1,64 @@
package streams
// Shell message type constants.
const (
// ShellMsgTypeInput indicates user input to the shell.
ShellMsgTypeInput = "input"
// ShellMsgTypeOutput indicates output from the shell.
ShellMsgTypeOutput = "output"
// ShellMsgTypePing is a ping message for keepalive.
ShellMsgTypePing = "ping"
// ShellMsgTypePong is a pong response to ping.
ShellMsgTypePong = "pong"
// ShellMsgTypeExit indicates the shell has exited.
ShellMsgTypeExit = "exit"
)
// ShellMessage is the message type for bidirectional shell stream.
// Used for interactive shell I/O over WebSocket.
//
// Stream endpoint: ws://.../api/v1/shell/stream
type ShellMessage struct {
// Type indicates the message type. Use ShellMsgType* constants:
// "input", "output", "ping", "pong", "exit".
Type string `json:"type"`
// Data contains the shell input or output data.
Data string `json:"data,omitempty"`
// Code is the exit code (for "exit" type only).
Code int `json:"code,omitempty"`
}
// ShellStatusResponse is the response from the shell status endpoint.
//
// HTTP endpoint: GET /api/v1/shell/status
type ShellStatusResponse struct {
// Running indicates if the shell is currently running.
Running bool `json:"running"`
// Pid is the process ID of the shell.
Pid int `json:"pid"`
// Shell is the shell type (e.g., "/bin/bash").
Shell string `json:"shell"`
// Cwd is the current working directory.
Cwd string `json:"cwd"`
// StartedAt is when the shell was started (ISO 8601 format).
StartedAt string `json:"started_at"`
}
// ShellBufferResponse is the response from the shell buffer endpoint.
//
// HTTP endpoint: GET /api/v1/shell/buffer
type ShellBufferResponse struct {
// Data contains the buffered shell output.
Data string `json:"data"`
}
+83 -110
View File
@@ -1,23 +1,85 @@
// Package types provides shared types for agentctl packages.
// This breaks import cycles between adapter and acp packages.
//
// For stream protocol message types, see the streams subpackage.
package types
import (
"context"
"time"
"github.com/kandev/kandev/internal/agentctl/types/streams"
)
// Permission action types - categorize the kind of action requiring approval
// Re-export stream types for convenience.
// New code may import from the streams package directly.
type (
// Agent event stream types
AgentEvent = streams.AgentEvent
PlanEntry = streams.PlanEntry
// Permission stream types
PermissionNotification = streams.PermissionNotification
PermissionOption = streams.PermissionOption
PermissionRespondRequest = streams.PermissionRespondRequest
PermissionRespondResponse = streams.PermissionRespondResponse
// Git stream types
GitStatusUpdate = streams.GitStatusUpdate
FileInfo = streams.FileInfo
// File stream types
FileChangeNotification = streams.FileChangeNotification
FileListUpdate = streams.FileListUpdate
FileEntry = streams.FileEntry
FileTreeNode = streams.FileTreeNode
FileTreeRequest = streams.FileTreeRequest
FileTreeResponse = streams.FileTreeResponse
FileContentRequest = streams.FileContentRequest
FileContentResponse = streams.FileContentResponse
// Shell stream types
ShellMessage = streams.ShellMessage
ShellStatusResponse = streams.ShellStatusResponse
ShellBufferResponse = streams.ShellBufferResponse
)
// Re-export stream constants for convenience.
const (
ActionTypeCommand = "command" // Shell command execution
ActionTypeFileWrite = "file_write" // File modification/creation
ActionTypeFileRead = "file_read" // File read (for sensitive files)
ActionTypeNetwork = "network" // Network access
ActionTypeMCPTool = "mcp_tool" // MCP tool invocation
ActionTypeOther = "other" // Other/unknown action type
// Agent event types (preferred)
EventTypeMessageChunk = streams.EventTypeMessageChunk
EventTypeReasoning = streams.EventTypeReasoning
EventTypeToolCall = streams.EventTypeToolCall
EventTypeToolUpdate = streams.EventTypeToolUpdate
EventTypePlan = streams.EventTypePlan
EventTypeComplete = streams.EventTypeComplete
EventTypeError = streams.EventTypeError
// Permission action types
ActionTypeCommand = streams.ActionTypeCommand
ActionTypeFileWrite = streams.ActionTypeFileWrite
ActionTypeFileRead = streams.ActionTypeFileRead
ActionTypeNetwork = streams.ActionTypeNetwork
ActionTypeMCPTool = streams.ActionTypeMCPTool
ActionTypeOther = streams.ActionTypeOther
// File operation types
FileOpCreate = streams.FileOpCreate
FileOpWrite = streams.FileOpWrite
FileOpRemove = streams.FileOpRemove
FileOpRename = streams.FileOpRename
FileOpChmod = streams.FileOpChmod
FileOpRefresh = streams.FileOpRefresh
// Shell message types
ShellMsgTypeInput = streams.ShellMsgTypeInput
ShellMsgTypeOutput = streams.ShellMsgTypeOutput
ShellMsgTypePing = streams.ShellMsgTypePing
ShellMsgTypePong = streams.ShellMsgTypePong
ShellMsgTypeExit = streams.ShellMsgTypeExit
)
// PermissionRequest represents a permission request from the agent
// PermissionRequest represents a permission request from the agent.
// This is used internally by adapters and is not sent over streams directly.
type PermissionRequest struct {
SessionID string `json:"session_id"`
ToolCallID string `json:"tool_call_id"`
@@ -35,18 +97,8 @@ type PermissionRequest struct {
ActionDetails map[string]interface{} `json:"action_details,omitempty"`
}
// PermissionOption represents a permission choice
type PermissionOption struct {
OptionID string `json:"option_id"`
Name string `json:"name"`
Kind string `json:"kind"` // allow_once, allow_always, reject_once, reject_always
// Metadata contains protocol-specific option data.
// For Codex: {"for_session": true} for session-wide approvals
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// PermissionResponse is the user's response to a permission request
// PermissionResponse is the user's response to a permission request.
// This is used internally by adapters.
type PermissionResponse struct {
OptionID string `json:"option_id,omitempty"`
Cancelled bool `json:"cancelled,omitempty"`
@@ -56,96 +108,17 @@ type PermissionResponse struct {
ResponseMetadata map[string]interface{} `json:"response_metadata,omitempty"`
}
// PermissionHandler is called when the agent requests permission for an action
// PermissionHandler is called when the agent requests permission for an action.
type PermissionHandler func(ctx context.Context, req *PermissionRequest) (*PermissionResponse, error)
// GitStatusUpdate represents a git status update
type GitStatusUpdate struct {
Timestamp time.Time `json:"timestamp"`
Modified []string `json:"modified"`
Added []string `json:"added"`
Deleted []string `json:"deleted"`
Untracked []string `json:"untracked"`
Renamed []string `json:"renamed"`
Ahead int `json:"ahead"`
Behind int `json:"behind"`
Branch string `json:"branch"`
RemoteBranch string `json:"remote_branch,omitempty"`
Files map[string]FileInfo `json:"files,omitempty"`
}
// Subscriber types for internal use.
type (
// GitStatusSubscriber is a channel that receives git status updates.
GitStatusSubscriber chan GitStatusUpdate
// FileInfo represents information about a file
type FileInfo struct {
Path string `json:"path"`
Status string `json:"status"` // modified, added, deleted, untracked, renamed
Additions int `json:"additions,omitempty"`
Deletions int `json:"deletions,omitempty"`
OldPath string `json:"old_path,omitempty"` // For renamed files
Diff string `json:"diff,omitempty"`
}
// FileListUpdate represents a file listing update
type FileListUpdate struct {
Timestamp time.Time `json:"timestamp"`
Files []FileEntry `json:"files"`
}
// FileEntry represents a file in the workspace
type FileEntry struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size,omitempty"`
}
// FileTreeNode represents a node in the file tree
type FileTreeNode struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size,omitempty"`
Children []*FileTreeNode `json:"children,omitempty"`
}
// FileTreeRequest represents a request for file tree
type FileTreeRequest struct {
Path string `json:"path"` // Path to get tree for (relative to workspace root)
Depth int `json:"depth"` // Depth to traverse (0 = unlimited, 1 = immediate children only)
}
// FileTreeResponse represents a response with file tree
type FileTreeResponse struct {
RequestID string `json:"request_id"`
Root *FileTreeNode `json:"root"`
Error string `json:"error,omitempty"`
}
// FileContentRequest represents a request for file content
type FileContentRequest struct {
Path string `json:"path"` // Path to file (relative to workspace root)
}
// FileContentResponse represents a response with file content
type FileContentResponse struct {
RequestID string `json:"request_id"`
Path string `json:"path"`
Content string `json:"content"`
Size int64 `json:"size"`
Error string `json:"error,omitempty"`
}
// FileChangeNotification represents a filesystem change notification
type FileChangeNotification struct {
Timestamp time.Time `json:"timestamp"`
Path string `json:"path"`
Operation string `json:"operation"` // create, write, remove, rename, chmod, refresh
}
// GitStatusSubscriber is a channel that receives git status updates
type GitStatusSubscriber chan GitStatusUpdate
// FilesSubscriber is a channel that receives file listing updates
type FilesSubscriber chan FileListUpdate
// FileChangeSubscriber is a channel that receives file change notifications
type FileChangeSubscriber chan FileChangeNotification
// FilesSubscriber is a channel that receives file listing updates.
FilesSubscriber chan FileListUpdate
// FileChangeSubscriber is a channel that receives file change notifications.
FileChangeSubscriber chan FileChangeNotification
)
+4 -3
View File
@@ -95,9 +95,10 @@ const (
// Event types for agent prompts
const (
PromptComplete = "prompt.complete" // Agent finished responding to a prompt
ToolCallStarted = "tool_call.started" // Agent started a tool call
ToolCallComplete = "tool_call.complete" // Agent finished a tool call
PromptComplete = "prompt.complete" // Agent finished responding to a prompt
ToolCallStarted = "tool_call.started" // Agent started a tool call
ToolCallComplete = "tool_call.complete" // Agent finished a tool call
PermissionRequestReceived = "permission_request.received" // Agent requested permission
)
// Event types for workspace/git status
@@ -169,11 +169,8 @@ func (c *Client) handleSubscribe(msg *ws.Message) {
})
c.sendMessage(resp)
// Send historical logs if available
// Send historical logs if available (now includes pending permission request messages)
c.sendHistoricalLogs(req.TaskID)
// Send pending permissions if available
c.sendPendingPermissions(req.TaskID)
}
type UserSubscribeRequest struct {
@@ -275,30 +272,9 @@ func (c *Client) sendHistoricalLogs(taskID string) {
}
}
// sendPendingPermissions sends any pending permission requests to the client
func (c *Client) sendPendingPermissions(taskID string) {
ctx := context.Background()
permissions, err := c.hub.GetPendingPermissions(ctx, taskID)
if err != nil {
c.logger.Error("Failed to get pending permissions",
zap.String("task_id", taskID),
zap.Error(err))
return
}
if len(permissions) == 0 {
return
}
c.logger.Debug("Sending pending permissions",
zap.String("task_id", taskID),
zap.Int("count", len(permissions)))
// Send each pending permission as a notification
for _, permission := range permissions {
c.sendMessage(permission)
}
}
// sendPendingPermissions is deprecated. Pending permissions are now stored as messages
// and sent via sendHistoricalLogs. This function is kept for reference but is unused.
// func (c *Client) sendPendingPermissions(taskID string) {}
// handleUnsubscribe handles task.unsubscribe action
func (c *Client) handleUnsubscribe(msg *ws.Message) {
+11 -15
View File
@@ -14,8 +14,9 @@ import (
// HistoricalLogsProvider is a function that retrieves historical logs for a task
type HistoricalLogsProvider func(ctx context.Context, taskID string) ([]*ws.Message, error)
// PendingPermissionsProvider is a function that retrieves pending permissions for a task
type PendingPermissionsProvider func(ctx context.Context, taskID string) ([]*ws.Message, error)
// PendingPermissionsProvider is deprecated. Pending permissions are now stored as messages
// and retrieved via the HistoricalLogsProvider.
// type PendingPermissionsProvider func(ctx context.Context, taskID string) ([]*ws.Message, error)
// Hub manages all WebSocket client connections
type Hub struct {
@@ -41,8 +42,8 @@ type Hub struct {
// Optional provider for historical logs on subscription
historicalLogsProvider HistoricalLogsProvider
// Optional provider for pending permissions on subscription
pendingPermissionsProvider PendingPermissionsProvider
// NOTE: pendingPermissionsProvider is deprecated. Pending permissions are now stored
// as messages and retrieved via the historicalLogsProvider.
mu sync.RWMutex
logger *logger.Logger
@@ -392,15 +393,10 @@ func (h *Hub) GetHistoricalLogs(ctx context.Context, taskID string) ([]*ws.Messa
return h.historicalLogsProvider(ctx, taskID)
}
// SetPendingPermissionsProvider sets the provider for pending permissions on subscription
func (h *Hub) SetPendingPermissionsProvider(provider PendingPermissionsProvider) {
h.pendingPermissionsProvider = provider
}
// SetPendingPermissionsProvider is deprecated. Pending permissions are now stored as messages
// and retrieved via the HistoricalLogsProvider. This method is a no-op for backwards compatibility.
// func (h *Hub) SetPendingPermissionsProvider(provider PendingPermissionsProvider) {}
// GetPendingPermissions retrieves pending permissions for a task if a provider is set
func (h *Hub) GetPendingPermissions(ctx context.Context, taskID string) ([]*ws.Message, error) {
if h.pendingPermissionsProvider == nil {
return nil, nil
}
return h.pendingPermissionsProvider(ctx, taskID)
}
// GetPendingPermissions is deprecated. Pending permissions are now stored as messages
// and retrieved via the HistoricalLogsProvider. This method always returns nil.
// func (h *Hub) GetPendingPermissions(ctx context.Context, taskID string) ([]*ws.Message, error) {}
+77 -11
View File
@@ -57,6 +57,8 @@ type MessageCreator interface {
CreateToolCallMessage(ctx context.Context, taskID, toolCallID, title, status, agentSessionID string, args map[string]interface{}) error
UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, status, result, agentSessionID string) error
CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType string, metadata map[string]interface{}, requestsInput bool) error
CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, pendingID, toolCallID, title string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error)
UpdatePermissionMessage(ctx context.Context, sessionID, pendingID, status string) error
}
// Service is the main orchestrator service
@@ -152,16 +154,17 @@ func NewService(
// Create the watcher with event handlers that wire everything together
handlers := watcher.EventHandlers{
OnTaskStateChanged: s.handleTaskStateChanged,
OnAgentReady: s.handleAgentReady,
OnAgentCompleted: s.handleAgentCompleted,
OnAgentFailed: s.handleAgentFailed,
OnACPMessage: s.handleACPMessage,
OnACPSessionCreated: s.handleACPSessionCreated,
OnPromptComplete: s.handlePromptComplete,
OnToolCallStarted: s.handleToolCallStarted,
OnToolCallComplete: s.handleToolCallComplete,
OnGitStatusUpdated: s.handleGitStatusUpdated,
OnTaskStateChanged: s.handleTaskStateChanged,
OnAgentReady: s.handleAgentReady,
OnAgentCompleted: s.handleAgentCompleted,
OnAgentFailed: s.handleAgentFailed,
OnACPMessage: s.handleACPMessage,
OnACPSessionCreated: s.handleACPSessionCreated,
OnPromptComplete: s.handlePromptComplete,
OnToolCallStarted: s.handleToolCallStarted,
OnToolCallComplete: s.handleToolCallComplete,
OnPermissionRequest: s.handlePermissionRequest,
OnGitStatusUpdated: s.handleGitStatusUpdated,
}
s.watcher = watcher.NewWatcher(eventBus, handlers, log)
@@ -554,7 +557,31 @@ func (s *Service) RespondToPermission(ctx context.Context, sessionID, pendingID,
zap.String("pending_id", pendingID),
zap.String("option_id", optionID),
zap.Bool("cancelled", cancelled))
return s.executor.RespondToPermission(ctx, sessionID, pendingID, optionID, cancelled)
// Respond to the permission via agentctl
if err := s.executor.RespondToPermission(ctx, sessionID, pendingID, optionID, cancelled); err != nil {
return err
}
// Determine status based on response
status := "approved"
if cancelled {
status = "rejected"
}
// Update the permission message with the new status
if s.messageCreator != nil {
if err := s.messageCreator.UpdatePermissionMessage(ctx, sessionID, pendingID, status); err != nil {
s.logger.Warn("failed to update permission message status",
zap.String("session_id", sessionID),
zap.String("pending_id", pendingID),
zap.String("status", status),
zap.Error(err))
// Don't fail the whole operation if message update fails
}
}
return nil
}
// CompleteTask explicitly completes a task and stops its agent
@@ -1180,3 +1207,42 @@ func (s *Service) handleToolCallComplete(ctx context.Context, data watcher.ToolC
s.updateTaskSessionState(ctx, data.TaskID, data.TaskSessionID, models.TaskSessionStateRunning, "", false)
}
}
// handlePermissionRequest handles permission request events and saves as message
func (s *Service) handlePermissionRequest(ctx context.Context, data watcher.PermissionRequestData) {
s.logger.Info("handling permission request",
zap.String("task_id", data.TaskID),
zap.String("pending_id", data.PendingID),
zap.String("title", data.Title))
if data.TaskSessionID == "" {
s.logger.Warn("missing session_id for permission_request",
zap.String("task_id", data.TaskID),
zap.String("pending_id", data.PendingID))
return
}
if s.messageCreator != nil {
_, err := s.messageCreator.CreatePermissionRequestMessage(
ctx,
data.TaskID,
data.TaskSessionID,
data.PendingID,
data.ToolCallID,
data.Title,
data.Options,
data.ActionType,
data.ActionDetails,
)
if err != nil {
s.logger.Error("failed to create permission request message",
zap.String("task_id", data.TaskID),
zap.String("pending_id", data.PendingID),
zap.Error(err))
} else {
s.logger.Info("created permission request message",
zap.String("task_id", data.TaskID),
zap.String("pending_id", data.PendingID))
}
}
}
@@ -69,6 +69,19 @@ type ToolCallCompleteData struct {
Result string `json:"result,omitempty"`
}
// PermissionRequestData contains data from permission_request events
type PermissionRequestData struct {
TaskID string `json:"task_id"`
TaskSessionID string `json:"session_id"`
AgentID string `json:"agent_id"`
PendingID string `json:"pending_id"`
ToolCallID string `json:"tool_call_id"`
Title string `json:"title"`
Options []map[string]interface{} `json:"options"`
ActionType string `json:"action_type"`
ActionDetails map[string]interface{} `json:"action_details"`
}
// GitStatusData contains data from git status events
type GitStatusData struct {
TaskID string `json:"task_id"`
@@ -113,6 +126,9 @@ type EventHandlers struct {
OnToolCallStarted func(ctx context.Context, data ToolCallData)
OnToolCallComplete func(ctx context.Context, data ToolCallCompleteData)
// Permission request events
OnPermissionRequest func(ctx context.Context, data PermissionRequestData)
// Git status events
OnGitStatusUpdated func(ctx context.Context, data GitStatusData)
}
@@ -187,6 +203,12 @@ func (w *Watcher) Start(ctx context.Context) error {
return err
}
// Subscribe to permission request events
if err := w.subscribeToPermissionRequestEvents(); err != nil {
w.unsubscribeAll()
return err
}
// Subscribe to git status events
if err := w.subscribeToGitStatusEvents(); err != nil {
w.unsubscribeAll()
@@ -547,6 +569,46 @@ func (w *Watcher) createToolCallCompleteHandler() bus.EventHandler {
}
}
// subscribeToPermissionRequestEvents subscribes to permission request events
func (w *Watcher) subscribeToPermissionRequestEvents() error {
if w.handlers.OnPermissionRequest == nil {
return nil
}
subject := events.PermissionRequestReceived + ".*"
sub, err := w.eventBus.Subscribe(subject, w.createPermissionRequestHandler())
if err != nil {
w.logger.Error("Failed to subscribe to permission request events",
zap.String("subject", subject),
zap.Error(err))
return err
}
w.subscriptions = append(w.subscriptions, sub)
return nil
}
// createPermissionRequestHandler creates a bus.EventHandler for permission request events
func (w *Watcher) createPermissionRequestHandler() bus.EventHandler {
return func(ctx context.Context, event *bus.Event) error {
var data PermissionRequestData
if err := w.parseEventData(event.Data, &data); err != nil {
w.logger.Error("Failed to parse permission request event data",
zap.String("event_type", event.Type),
zap.String("event_id", event.ID),
zap.Error(err))
return nil
}
w.logger.Debug("Handling permission request event",
zap.String("task_id", data.TaskID),
zap.String("pending_id", data.PendingID),
zap.String("title", data.Title))
w.handlers.OnPermissionRequest(ctx, data)
return nil
}
}
// subscribeToGitStatusEvents subscribes to git status events
func (w *Watcher) subscribeToGitStatusEvents() error {
if w.handlers.OnGitStatusUpdated == nil {
@@ -96,6 +96,8 @@ const (
MessageTypeError MessageType = "error"
// MessageTypeStatus is for status changes: started, completed, failed
MessageTypeStatus MessageType = "status"
// MessageTypePermissionRequest is for agent permission requests
MessageTypePermissionRequest MessageType = "permission_request"
)
// Message represents a message in a task session
@@ -62,6 +62,7 @@ type Repository interface {
CreateMessage(ctx context.Context, message *models.Message) error
GetMessage(ctx context.Context, id string) (*models.Message, error)
GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error)
GetMessageByPendingID(ctx context.Context, sessionID, pendingID string) (*models.Message, error)
UpdateMessage(ctx context.Context, message *models.Message) error
ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error)
ListMessagesPaginated(ctx context.Context, sessionID string, opts ListMessagesOptions) ([]*models.Message, bool, error)
@@ -1546,7 +1546,7 @@ func (r *SQLiteRepository) DeleteMessage(ctx context.Context, id string) error {
return nil
}
// GetMessageByToolCallID retrieves a message by session ID and tool_call_id in metadata
// GetMessageByToolCallID retrieves a tool_call message by session ID and tool_call_id in metadata
func (r *SQLiteRepository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) {
message := &models.Message{}
var requestsInput int
@@ -1554,7 +1554,7 @@ func (r *SQLiteRepository) GetMessageByToolCallID(ctx context.Context, sessionID
var metadataJSON string
err := r.db.QueryRowContext(ctx, `
SELECT id, task_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE task_session_id = ? AND json_extract(metadata, '$.tool_call_id') = ?
FROM task_session_messages WHERE task_session_id = ? AND type = 'tool_call' AND json_extract(metadata, '$.tool_call_id') = ?
`, sessionID, toolCallID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.AuthorType, &message.AuthorID,
&message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt)
if err != nil {
@@ -1568,6 +1568,28 @@ func (r *SQLiteRepository) GetMessageByToolCallID(ctx context.Context, sessionID
return message, nil
}
// GetMessageByPendingID retrieves a message by session ID and pending_id in metadata
func (r *SQLiteRepository) GetMessageByPendingID(ctx context.Context, sessionID, pendingID string) (*models.Message, error) {
message := &models.Message{}
var requestsInput int
var messageType string
var metadataJSON string
err := r.db.QueryRowContext(ctx, `
SELECT id, task_session_id, task_id, author_type, author_id, content, requests_input, type, metadata, created_at
FROM task_session_messages WHERE task_session_id = ? AND json_extract(metadata, '$.pending_id') = ?
`, sessionID, pendingID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.AuthorType, &message.AuthorID,
&message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt)
if err != nil {
return nil, err
}
message.RequestsInput = requestsInput == 1
message.Type = models.MessageType(messageType)
if metadataJSON != "" {
_ = json.Unmarshal([]byte(metadataJSON), &message.Metadata)
}
return message, nil
}
// UpdateMessage updates an existing message
func (r *SQLiteRepository) UpdateMessage(ctx context.Context, message *models.Message) error {
metadataJSON, err := json.Marshal(message.Metadata)
@@ -1611,6 +1611,69 @@ func (s *Service) UpdateToolCallMessage(ctx context.Context, sessionID, toolCall
return nil
}
// UpdatePermissionMessage updates a permission request message's status.
// It includes retry logic to handle race conditions.
func (s *Service) UpdatePermissionMessage(ctx context.Context, sessionID, pendingID, status string) error {
const maxRetries = 5
const retryDelay = 100 * time.Millisecond
var message *models.Message
var err error
// Retry loop to handle race condition
for attempt := 0; attempt < maxRetries; attempt++ {
message, err = s.repo.GetMessageByPendingID(ctx, sessionID, pendingID)
if err == nil {
break
}
if ctx.Err() != nil {
return ctx.Err()
}
if attempt < maxRetries-1 {
s.logger.Debug("permission message not found, retrying",
zap.String("session_id", sessionID),
zap.String("pending_id", pendingID),
zap.Int("attempt", attempt+1),
zap.Int("max_retries", maxRetries))
time.Sleep(retryDelay)
}
}
if err != nil {
s.logger.Warn("permission message not found for update after retries",
zap.String("session_id", sessionID),
zap.String("pending_id", pendingID),
zap.Int("retries", maxRetries),
zap.Error(err))
return err
}
if message.Metadata == nil {
message.Metadata = make(map[string]interface{})
}
message.Metadata["status"] = status
if err := s.repo.UpdateMessage(ctx, message); err != nil {
s.logger.Error("failed to update permission message",
zap.String("message_id", message.ID),
zap.String("pending_id", pendingID),
zap.Error(err))
return err
}
// Publish message.updated event
s.publishMessageEvent(ctx, events.MessageUpdated, message)
s.logger.Info("permission message updated",
zap.String("message_id", message.ID),
zap.String("pending_id", pendingID),
zap.String("status", status))
return nil
}
// publishMessageEvent publishes message events to the event bus
func (s *Service) publishMessageEvent(ctx context.Context, eventType string, message *models.Message) {
if s.eventBus == nil {
-4
View File
@@ -5,7 +5,6 @@ import '@git-diff-view/react/styles/diff-view.css';
import { TooltipProvider } from '@kandev/ui/tooltip';
import { TaskTopBar } from '@/components/task/task-top-bar';
import { TaskLayout } from '@/components/task/task-layout';
import { CommandApprovalDialog } from '@/components/task/command-approval-dialog';
import { DebugOverlay } from '@/components/debug-overlay';
import type { Repository, Task } from '@/lib/types/http';
import { DEBUG_UI } from '@/lib/config';
@@ -174,9 +173,6 @@ export default function TaskPage({
workspaceId={task?.workspace_id ?? null}
boardId={task?.board_id ?? null}
/>
{/* Fallback dialog for permissions without matching tool call (e.g., workspace indexing) */}
{task?.id && <CommandApprovalDialog taskId={task.id} standaloneOnly />}
</div>
</TooltipProvider>
);
@@ -3,6 +3,7 @@
import type { ReactElement } from 'react';
import type { Message } from '@/lib/types/http';
import { ChatMessage } from '@/components/task/chat/messages/chat-message';
import { PermissionRequestMessage } from '@/components/task/chat/messages/permission-request-message';
import { StatusMessage } from '@/components/task/chat/messages/status-message';
import { ToolCallMessage } from '@/components/task/chat/messages/tool-call-message';
import { ThinkingMessage } from '@/components/task/chat/messages/thinking-message';
@@ -11,6 +12,7 @@ import { TodoMessage } from '@/components/task/chat/messages/todo-message';
type AdapterContext = {
isTaskDescription: boolean;
taskId?: string;
permissionsByToolCallId?: Map<string, Message>;
};
type MessageAdapter = {
@@ -29,12 +31,21 @@ const adapters: MessageAdapter[] = [
},
{
matches: (comment) => comment.type === 'tool_call',
render: (comment, ctx) => <ToolCallMessage comment={comment} taskId={ctx.taskId} />,
render: (comment, ctx) => {
const toolCallId = (comment.metadata as { tool_call_id?: string } | undefined)?.tool_call_id;
const permissionMessage = toolCallId ? ctx.permissionsByToolCallId?.get(toolCallId) : undefined;
return <ToolCallMessage comment={comment} permissionMessage={permissionMessage} />;
},
},
{
matches: (comment) => comment.type === 'error' || comment.type === 'status' || comment.type === 'progress',
render: (comment) => <StatusMessage comment={comment} />,
},
{
// Standalone permission requests (no matching tool call)
matches: (comment) => comment.type === 'permission_request',
render: (comment) => <PermissionRequestMessage comment={comment} />,
},
{
matches: () => true,
render: (comment, ctx) => {
@@ -73,10 +84,11 @@ type MessageRendererProps = {
comment: Message;
isTaskDescription: boolean;
taskId?: string;
permissionsByToolCallId?: Map<string, Message>;
};
export function MessageRenderer({ comment, isTaskDescription, taskId }: MessageRendererProps) {
const ctx = { isTaskDescription, taskId };
export function MessageRenderer({ comment, isTaskDescription, taskId, permissionsByToolCallId }: MessageRendererProps) {
const ctx = { isTaskDescription, taskId, permissionsByToolCallId };
const adapter = adapters.find((entry) => entry.matches(comment, ctx)) ?? adapters[adapters.length - 1];
return adapter.render(comment, ctx);
}
@@ -0,0 +1,170 @@
'use client';
import { useCallback, useState } from 'react';
import {
IconAlertTriangle,
IconCheck,
IconX,
IconLoader2,
} from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { cn } from '@/lib/utils';
import { getWebSocketClient } from '@/lib/ws/connection';
import type { Message } from '@/lib/types/http';
type PermissionOption = {
option_id: string;
name: string;
kind: string;
};
type PermissionRequestMetadata = {
pending_id: string;
tool_call_id?: string;
options: PermissionOption[];
action_type: string;
action_details?: {
command?: string;
path?: string;
cwd?: string;
};
status?: 'pending' | 'approved' | 'rejected';
};
type PermissionRequestMessageProps = {
comment: Message;
};
export function PermissionRequestMessage({ comment }: PermissionRequestMessageProps) {
const [isResponding, setIsResponding] = useState(false);
const metadata = comment.metadata as PermissionRequestMetadata | undefined;
const isResolved = metadata?.status === 'approved' || metadata?.status === 'rejected';
const isPending = !isResolved;
const handleRespond = useCallback(
async (optionId: string, cancelled: boolean = false) => {
if (!metadata) return;
const client = getWebSocketClient();
if (!client) {
console.error('WebSocket client not available');
return;
}
setIsResponding(true);
try {
await client.request('permission.respond', {
session_id: comment.session_id,
pending_id: metadata.pending_id,
option_id: cancelled ? undefined : optionId,
cancelled,
});
} catch (error) {
console.error('Failed to respond to permission request:', error);
} finally {
setIsResponding(false);
}
},
[comment.session_id, metadata]
);
const handleApprove = useCallback(() => {
const allowOption = metadata?.options.find(
(opt) => opt.kind === 'allow_once' || opt.kind === 'allow_always'
);
if (allowOption) {
handleRespond(allowOption.option_id);
}
}, [metadata, handleRespond]);
const handleReject = useCallback(() => {
const rejectOption = metadata?.options.find(
(opt) => opt.kind === 'reject_once' || opt.kind === 'reject_always'
);
if (rejectOption) {
handleRespond(rejectOption.option_id);
} else {
handleRespond('', true);
}
}, [metadata, handleRespond]);
const getStatusBadge = () => {
if (metadata?.status === 'approved') {
return (
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<IconCheck className="h-3 w-3" /> Approved
</span>
);
}
if (metadata?.status === 'rejected') {
return (
<span className="inline-flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
<IconX className="h-3 w-3" /> Rejected
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<IconLoader2 className="h-3 w-3 animate-spin" /> Pending Approval
</span>
);
};
return (
<div
className={cn(
'w-full rounded-md border overflow-hidden',
isPending ? 'border-amber-500/50 bg-amber-500/5' : 'border-border/50 bg-muted/20'
)}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 text-sm">
<IconAlertTriangle
className={cn(
'h-4 w-4 flex-shrink-0',
isPending ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground'
)}
/>
<span className="flex-1 font-medium text-foreground truncate">
{comment.content || 'Permission Required'}
</span>
{getStatusBadge()}
</div>
{/* Approval buttons - only show when pending */}
{isPending && (
<div className="flex items-center gap-2 px-3 py-2 border-t border-amber-500/30 bg-amber-500/10">
<span className="text-xs text-amber-600 dark:text-amber-400 flex-1">
Approve this action?
</span>
<Button
size="sm"
variant="ghost"
onClick={handleReject}
disabled={isResponding}
className="h-7 px-2 text-red-600 hover:text-red-700 hover:bg-red-500/10"
>
<IconX className="h-4 w-4 mr-1" />
Deny
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleApprove}
disabled={isResponding}
className="h-7 px-2 text-green-600 hover:text-green-700 hover:bg-green-500/10"
>
{isResponding ? (
<IconLoader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<IconCheck className="h-4 w-4 mr-1" />
)}
Approve
</Button>
</div>
)}
</div>
);
}
@@ -16,10 +16,28 @@ import {
} from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { cn } from '@/lib/utils';
import { getWebSocketClient } from '@/lib/ws/connection';
import type { Message } from '@/lib/types/http';
import type { ToolCallMetadata } from '@/components/task/chat/types';
import { useAppStore } from '@/components/state-provider';
import { getWebSocketClient } from '@/lib/ws/connection';
type PermissionOption = {
option_id: string;
name: string;
kind: string;
};
type PermissionRequestMetadata = {
pending_id: string;
tool_call_id: string;
options: PermissionOption[];
action_type: string;
action_details: {
command?: string;
path?: string;
cwd?: string;
};
status?: 'pending' | 'approved' | 'rejected';
};
function getToolIcon(toolName: string | undefined, className: string) {
const name = toolName?.toLowerCase() ?? '';
@@ -41,7 +59,15 @@ function getToolIcon(toolName: string | undefined, className: string) {
return <IconCode className={className} />;
}
function getStatusIcon(status?: string) {
function getStatusIcon(status?: string, permissionStatus?: string) {
// If there's a permission status, show that instead
if (permissionStatus === 'approved') {
return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
}
if (permissionStatus === 'rejected') {
return <IconX className="h-3.5 w-3.5 text-red-500" />;
}
switch (status) {
case 'complete':
return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
@@ -56,74 +82,64 @@ function getStatusIcon(status?: string) {
type ToolCallMessageProps = {
comment: Message;
taskId?: string;
permissionMessage?: Message;
};
export function ToolCallMessage({ comment, taskId }: ToolCallMessageProps) {
export function ToolCallMessage({ comment, permissionMessage }: ToolCallMessageProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isResponding, setIsResponding] = useState(false);
const metadata = comment.metadata as ToolCallMetadata | undefined;
const pendingPermissions = useAppStore((state) => state.permissions.pending);
const removePendingPermission = useAppStore((state) => state.removePendingPermission);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const permissionMetadata = permissionMessage?.metadata as PermissionRequestMetadata | undefined;
const toolCallId = metadata?.tool_call_id;
const toolName = metadata?.tool_name ?? '';
const title = metadata?.title ?? comment.content ?? 'Tool call';
const status = metadata?.status;
const args = metadata?.args;
const result = metadata?.result;
// Find pending permission for this tool call (only if we have a valid tool_call_id)
const pendingPermission = pendingPermissions.find(
(p) =>
toolCallId &&
p.tool_call_id === toolCallId &&
(!activeSessionId || p.session_id === activeSessionId) &&
(!taskId || p.task_id === taskId)
);
const isPendingApproval = !!pendingPermission;
// Permission state
const hasPermission = !!permissionMessage;
const permissionStatus = permissionMetadata?.status;
const isPermissionPending = hasPermission && permissionStatus !== 'approved' && permissionStatus !== 'rejected';
const handleRespond = useCallback(
async (optionId: string, cancelled: boolean = false) => {
if (!pendingPermission) return;
setIsResponding(true);
if (!permissionMetadata || !permissionMessage) return;
const client = getWebSocketClient();
if (!client) {
console.error('WebSocket client not available');
setIsResponding(false);
return;
}
setIsResponding(true);
try {
await client.request('permission.respond', {
session_id: pendingPermission.session_id,
pending_id: pendingPermission.pending_id,
session_id: permissionMessage.session_id,
pending_id: permissionMetadata.pending_id,
option_id: cancelled ? undefined : optionId,
cancelled,
});
removePendingPermission(pendingPermission.pending_id);
} catch (error) {
console.error('Failed to respond to permission request:', error);
} finally {
setIsResponding(false);
}
},
[pendingPermission, removePendingPermission]
[permissionMessage, permissionMetadata]
);
const handleApprove = useCallback(() => {
const allowOption = pendingPermission?.options.find(
const allowOption = permissionMetadata?.options.find(
(opt) => opt.kind === 'allow_once' || opt.kind === 'allow_always'
);
if (allowOption) {
handleRespond(allowOption.option_id);
}
}, [pendingPermission, handleRespond]);
}, [permissionMetadata, handleRespond]);
const handleReject = useCallback(() => {
const rejectOption = pendingPermission?.options.find(
const rejectOption = permissionMetadata?.options.find(
(opt) => opt.kind === 'reject_once' || opt.kind === 'reject_always'
);
if (rejectOption) {
@@ -131,9 +147,12 @@ export function ToolCallMessage({ comment, taskId }: ToolCallMessageProps) {
} else {
handleRespond('', true);
}
}, [pendingPermission, handleRespond]);
}, [permissionMetadata, handleRespond]);
const toolIcon = getToolIcon(toolName, 'h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0');
const toolIcon = getToolIcon(toolName, cn(
'h-4 w-4 flex-shrink-0',
isPermissionPending ? 'text-amber-600 dark:text-amber-400' : 'text-amber-600 dark:text-amber-400'
));
const hasDetails = args && Object.keys(args).length > 0;
let filePath: string | undefined;
@@ -151,9 +170,7 @@ export function ToolCallMessage({ comment, taskId }: ToolCallMessageProps) {
return (
<div className={cn(
'w-full rounded-md border overflow-hidden',
isPendingApproval
? 'border-amber-500/50 bg-amber-500/5'
: 'border-border/50 bg-muted/20'
isPermissionPending ? 'border-amber-500/50 bg-amber-500/5' : 'border-border/50 bg-muted/20'
)}>
<button
type="button"
@@ -173,7 +190,7 @@ export function ToolCallMessage({ comment, taskId }: ToolCallMessageProps) {
{filePath}
</span>
)}
{!isPendingApproval && getStatusIcon(status)}
{getStatusIcon(status, permissionStatus)}
{hasDetails && (
isExpanded
? <IconChevronDown className="h-4 w-4 text-muted-foreground/50" />
@@ -181,39 +198,6 @@ export function ToolCallMessage({ comment, taskId }: ToolCallMessageProps) {
)}
</button>
{/* Inline approval buttons when permission is pending */}
{isPendingApproval && (
<div className="flex items-center gap-2 px-3 py-2 border-t border-amber-500/30 bg-amber-500/10">
<span className="text-xs text-amber-600 dark:text-amber-400 flex-1">
Approve this action?
</span>
<Button
size="sm"
variant="ghost"
onClick={handleReject}
disabled={isResponding}
className="h-7 px-2 text-red-600 hover:text-red-700 hover:bg-red-500/10"
>
<IconX className="h-4 w-4 mr-1" />
Deny
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleApprove}
disabled={isResponding}
className="h-7 px-2 text-green-600 hover:text-green-700 hover:bg-green-500/10"
>
{isResponding ? (
<IconLoader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<IconCheck className="h-4 w-4 mr-1" />
)}
Approve
</Button>
</div>
)}
{isExpanded && hasDetails && (
<div className="border-t border-border/30 bg-background/50 p-3 space-y-2">
{args && Object.entries(args).map(([key, value]) => {
@@ -243,6 +227,39 @@ export function ToolCallMessage({ comment, taskId }: ToolCallMessageProps) {
)}
</div>
)}
{/* Inline permission approval UI */}
{isPermissionPending && (
<div className="flex items-center gap-2 px-3 py-2 border-t border-amber-500/30 bg-amber-500/10">
<span className="text-xs text-amber-600 dark:text-amber-400 flex-1">
Approve this action?
</span>
<Button
size="sm"
variant="ghost"
onClick={handleReject}
disabled={isResponding}
className="h-7 px-2 text-red-600 hover:text-red-700 hover:bg-red-500/10"
>
<IconX className="h-4 w-4 mr-1" />
Deny
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleApprove}
disabled={isResponding}
className="h-7 px-2 text-green-600 hover:text-green-700 hover:bg-green-500/10"
>
{isResponding ? (
<IconLoader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<IconCheck className="h-4 w-4 mr-1" />
)}
Approve
</Button>
</div>
)}
</div>
);
}
@@ -1,187 +0,0 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { IconTerminal2, IconAlertTriangle, IconLoader2 } from '@tabler/icons-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@kandev/ui/alert-dialog';
import { useAppStore } from '@/components/state-provider';
import { getWebSocketClient } from '@/lib/ws/connection';
import type { Message } from '@/lib/types/http';
const EMPTY_MESSAGES: Message[] = [];
type CommandApprovalDialogProps = {
taskId: string;
/** Only show permissions that don't have a matching tool call (standalone permissions like workspace indexing) */
standaloneOnly?: boolean;
};
export function CommandApprovalDialog({ taskId, standaloneOnly = false }: CommandApprovalDialogProps) {
const [isResponding, setIsResponding] = useState(false);
const pendingPermissions = useAppStore((state) => state.permissions.pending);
const removePendingPermission = useAppStore((state) => state.removePendingPermission);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const messages = useAppStore((state) =>
activeSessionId ? state.messages.bySession[activeSessionId] ?? EMPTY_MESSAGES : EMPTY_MESSAGES
);
// Get tool_call_ids from all tool call messages
const toolCallIds = useMemo(() => {
const ids = new Set<string>();
for (const msg of messages) {
const tcid = msg.metadata?.tool_call_id;
if (msg.type === 'tool_call' && typeof tcid === 'string' && tcid) {
ids.add(tcid);
}
}
return ids;
}, [messages]);
// Get the first pending permission for this task
// If standaloneOnly, filter out permissions that have a matching tool call
const permission = useMemo(() => {
return pendingPermissions.find((p) => {
if (activeSessionId) {
if (p.session_id !== activeSessionId) return false;
} else if (p.task_id !== taskId) {
return false;
}
if (standaloneOnly && p.tool_call_id && toolCallIds.has(p.tool_call_id)) {
return false; // This permission will be handled by inline UI
}
return true;
});
}, [pendingPermissions, activeSessionId, taskId, standaloneOnly, toolCallIds]);
const handleRespond = useCallback(
async (optionId: string, cancelled: boolean = false) => {
if (!permission) return;
setIsResponding(true);
const client = getWebSocketClient();
if (!client) {
console.error('WebSocket client not available');
setIsResponding(false);
return;
}
try {
await client.request('permission.respond', {
session_id: permission.session_id,
pending_id: permission.pending_id,
option_id: cancelled ? undefined : optionId,
cancelled,
});
removePendingPermission(permission.pending_id);
} catch (error) {
console.error('Failed to respond to permission request:', error);
} finally {
setIsResponding(false);
}
},
[permission, removePendingPermission]
);
const handleApprove = useCallback(() => {
// Find the "allow" option (allow_once or allow_always)
const allowOption = permission?.options.find(
(opt) => opt.kind === 'allow_once' || opt.kind === 'allow_always'
);
if (allowOption) {
handleRespond(allowOption.option_id);
}
}, [permission, handleRespond]);
const handleReject = useCallback(() => {
// Find the "reject" option (reject_once or reject_always)
const rejectOption = permission?.options.find(
(opt) => opt.kind === 'reject_once' || opt.kind === 'reject_always'
);
if (rejectOption) {
handleRespond(rejectOption.option_id);
} else {
// If no reject option, cancel the request
handleRespond('', true);
}
}, [permission, handleRespond]);
if (!permission) {
return null;
}
// Extract command details from action_details
const actionDetails = permission.action_details || {};
const command = actionDetails.command as string | undefined;
const cwd = actionDetails.cwd as string | undefined;
const reasoning = actionDetails.reasoning as string | undefined;
return (
<AlertDialog open={!!permission}>
<AlertDialogContent className="max-w-lg">
<AlertDialogHeader>
<div className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/10">
<IconAlertTriangle className="h-5 w-5 text-amber-500" />
</div>
<AlertDialogTitle className="text-base">
{permission.title || 'Command Execution Approval'}
</AlertDialogTitle>
</div>
<AlertDialogDescription className="text-left">
The agent is requesting permission to execute a command.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-3">
{command && (
<div className="rounded-md border bg-muted/50 p-3">
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<IconTerminal2 className="h-3.5 w-3.5" />
Command
</div>
<code className="block whitespace-pre-wrap break-all font-mono text-sm">
{command}
</code>
</div>
)}
{cwd && (
<div className="text-xs text-muted-foreground">
<span className="font-medium">Working directory:</span> {cwd}
</div>
)}
{reasoning && (
<div className="text-sm text-muted-foreground">
<span className="font-medium">Reason:</span> {reasoning}
</div>
)}
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleReject} disabled={isResponding}>
Reject
</AlertDialogCancel>
<AlertDialogAction onClick={handleApprove} variant="default" disabled={isResponding}>
{isResponding ? (
<>
<IconLoader2 className="h-4 w-4 mr-1 animate-spin" />
Approving...
</>
) : (
'Approve'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+57 -12
View File
@@ -1,6 +1,6 @@
'use client';
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import { IconBrain, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react';
@@ -68,18 +68,62 @@ export function TaskChatPanel({
const showLoadingState = (messagesLoading || isInitialLoading) && !isWorking;
const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(resolvedSessionId);
// Build a set of tool_call_ids that exist in the messages
const toolCallIds = useMemo(() => {
const set = new Set<string>();
for (const message of messages) {
if (message.type === 'tool_call') {
const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id;
if (toolCallId) {
set.add(toolCallId);
}
}
}
return set;
}, [messages]);
// Build a lookup map of tool_call_id → permission_request message
const permissionsByToolCallId = useMemo(() => {
const map = new Map<string, Message>();
for (const message of messages) {
if (message.type === 'permission_request') {
const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id;
if (toolCallId) {
map.set(toolCallId, message);
}
}
}
return map;
}, [messages]);
// Filter to only show chat-relevant types.
const visibleMessages = messages.filter((message) =>
!message.type ||
message.type === 'message' ||
message.type === 'content' ||
message.type === 'tool_call' ||
message.type === 'progress' ||
message.type === 'status' ||
message.type === 'error' ||
message.type === 'thinking' ||
message.type === 'todo'
);
// Permission requests are only hidden if they have a matching tool_call in the messages
// (so they can be displayed inline). Standalone permissions are shown as separate messages.
const visibleMessages = messages.filter((message) => {
// Standard visible types
if (
!message.type ||
message.type === 'message' ||
message.type === 'content' ||
message.type === 'tool_call' ||
message.type === 'progress' ||
message.type === 'status' ||
message.type === 'error' ||
message.type === 'thinking' ||
message.type === 'todo'
) {
return true;
}
// Permission requests: show if no matching tool_call exists (standalone permission)
if (message.type === 'permission_request') {
const toolCallId = (message.metadata as { tool_call_id?: string } | undefined)?.tool_call_id;
// Show as standalone if no tool_call_id or no matching tool_call message exists
return !toolCallId || !toolCallIds.has(toolCallId);
}
return false;
});
// Create a synthetic "user" message for the task description
const taskDescriptionMessage: Message | null =
@@ -248,6 +292,7 @@ export function TaskChatPanel({
comment={message}
isTaskDescription={message.id === 'task-description'}
taskId={taskId ?? undefined}
permissionsByToolCallId={permissionsByToolCallId}
/>
</div>
</div>
-57
View File
@@ -175,30 +175,6 @@ export type DiffState = {
files: Array<{ path: string; status: 'A' | 'M' | 'D'; plus: number; minus: number }>;
};
export type PermissionOption = {
option_id: string;
name: string;
kind: string; // allow_once, allow_always, reject_once, reject_always
};
export type PendingPermission = {
pending_id: string;
task_id: string;
agent_instance_id: string;
session_id: string;
tool_call_id: string;
title: string;
options: PermissionOption[];
action_type?: string; // command, file_write, file_read, network, mcp_tool, other
action_details?: Record<string, unknown>;
created_at: string;
timestamp: string;
};
export type PermissionsState = {
pending: PendingPermission[];
};
export type ConnectionState = {
status: 'disconnected' | 'connecting' | 'connected' | 'error' | 'reconnecting';
error: string | null;
@@ -263,7 +239,6 @@ export type AppState = {
sessionAgentctl: SessionAgentctlState;
worktrees: WorktreesState;
sessionWorktreesBySessionId: SessionWorktreesState;
permissions: PermissionsState;
hydrate: (state: Partial<AppState>) => void;
setActiveWorkspace: (workspaceId: string | null) => void;
setWorkspaces: (workspaces: WorkspaceState['items']) => void;
@@ -317,9 +292,6 @@ export type AppState = {
setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => void;
clearGitStatus: (sessionId: string) => void;
bumpAgentProfilesVersion: () => void;
addPendingPermission: (permission: PendingPermission) => void;
removePendingPermission: (pendingId: string) => void;
clearPendingPermissions: (sessionId?: string) => void;
};
export type AppStore = ReturnType<typeof createAppStore>;
@@ -356,7 +328,6 @@ const defaultState: AppState = {
sessionAgentctl: { itemsBySessionId: {} },
worktrees: { items: {} },
sessionWorktreesBySessionId: { itemsBySessionId: {} },
permissions: { pending: [] },
hydrate: () => undefined,
setActiveWorkspace: () => undefined,
setWorkspaces: () => undefined,
@@ -396,9 +367,6 @@ const defaultState: AppState = {
setGitStatus: () => undefined,
clearGitStatus: () => undefined,
bumpAgentProfilesVersion: () => undefined,
addPendingPermission: () => undefined,
removePendingPermission: () => undefined,
clearPendingPermissions: () => undefined,
};
function mergeInitialState(
@@ -444,9 +412,6 @@ function mergeInitialState(
| 'setGitStatus'
| 'clearGitStatus'
| 'bumpAgentProfilesVersion'
| 'addPendingPermission'
| 'removePendingPermission'
| 'clearPendingPermissions'
> {
if (!initialState) return defaultState;
return {
@@ -478,7 +443,6 @@ function mergeInitialState(
...defaultState.sessionWorktreesBySessionId,
...initialState.sessionWorktreesBySessionId,
},
permissions: { ...defaultState.permissions, ...initialState.permissions },
};
}
@@ -521,7 +485,6 @@ export function createAppStore(initialState?: Partial<AppState>) {
if (state.sessionWorktreesBySessionId) {
Object.assign(draft.sessionWorktreesBySessionId, state.sessionWorktreesBySessionId);
}
if (state.permissions) Object.assign(draft.permissions, state.permissions);
}),
setActiveWorkspace: (workspaceId) =>
set((draft) => {
@@ -793,26 +756,6 @@ export function createAppStore(initialState?: Partial<AppState>) {
set((draft) => {
draft.agentProfiles.version += 1;
}),
addPendingPermission: (permission) =>
set((draft) => {
// Avoid duplicates
const exists = draft.permissions.pending.some((p) => p.pending_id === permission.pending_id);
if (!exists) {
draft.permissions.pending.push(permission);
}
}),
removePendingPermission: (pendingId) =>
set((draft) => {
draft.permissions.pending = draft.permissions.pending.filter((p) => p.pending_id !== pendingId);
}),
clearPendingPermissions: (sessionId) =>
set((draft) => {
if (sessionId) {
draft.permissions.pending = draft.permissions.pending.filter((p) => p.session_id !== sessionId);
} else {
draft.permissions.pending = [];
}
}),
}))
);
}
+1 -23
View File
@@ -36,8 +36,7 @@ export type BackendMessageType =
| 'agent.profile.updated'
| 'user.settings.updated'
| 'session.workspace.file.changes'
| 'session.shell.output'
| 'permission.requested';
| 'session.shell.output';
export type BackendMessage<T extends BackendMessageType, P> = {
id?: string;
@@ -254,26 +253,6 @@ export type ShellOutputPayload = {
code?: number;
};
export type PermissionOptionPayload = {
option_id: string;
name: string;
kind: string; // allow_once, allow_always, reject_once, reject_always
};
export type PermissionRequestedPayload = {
pending_id: string;
task_id: string;
agent_instance_id: string;
session_id: string;
tool_call_id: string;
title: string;
options: PermissionOptionPayload[];
action_type?: string; // command, file_write, file_read, network, mcp_tool, other
action_details?: Record<string, unknown>;
created_at: string;
timestamp: string;
};
export type BackendMessageMap = {
'kanban.update': BackendMessage<'kanban.update', KanbanUpdatePayload>;
'task.created': BackendMessage<'task.created', TaskEventPayload>;
@@ -313,7 +292,6 @@ export type BackendMessageMap = {
'user.settings.updated': BackendMessage<'user.settings.updated', UserSettingsUpdatedPayload>;
'session.workspace.file.changes': BackendMessage<'session.workspace.file.changes', FileChangeNotificationPayload>;
'session.shell.output': BackendMessage<'session.shell.output', ShellOutputPayload>;
'permission.requested': BackendMessage<'permission.requested', PermissionRequestedPayload>;
};
// Workspace file types
+2 -1
View File
@@ -307,7 +307,8 @@ export type MessageType =
| 'error'
| 'status'
| 'thinking'
| 'todo';
| 'todo'
| 'permission_request';
export type Message = {
id: string;
+3 -2
View File
@@ -1,6 +1,7 @@
import type { StoreApi } from 'zustand';
import type { AppState } from '@/lib/state/store';
import type { WsHandlers } from '@/lib/ws/handlers/types';
import type { MessageType } from '@/lib/types/http';
export function registerMessagesHandlers(store: StoreApi<AppState>): WsHandlers {
return {
@@ -16,7 +17,7 @@ export function registerMessagesHandlers(store: StoreApi<AppState>): WsHandlers
author_type: payload.author_type,
author_id: payload.author_id,
content: payload.content,
type: (payload.type as 'message' | 'content' | 'tool_call' | 'progress' | 'error' | 'status') || 'message',
type: (payload.type as MessageType) || 'message',
metadata: payload.metadata,
requests_input: payload.requests_input,
created_at: payload.created_at,
@@ -34,7 +35,7 @@ export function registerMessagesHandlers(store: StoreApi<AppState>): WsHandlers
author_type: payload.author_type,
author_id: payload.author_id,
content: payload.content,
type: (payload.type as 'message' | 'content' | 'tool_call' | 'progress' | 'error' | 'status') || 'message',
type: (payload.type as MessageType) || 'message',
metadata: payload.metadata,
requests_input: payload.requests_input,
created_at: payload.created_at,
-29
View File
@@ -1,29 +0,0 @@
import type { StoreApi } from 'zustand';
import type { AppState, PendingPermission } from '@/lib/state/store';
import type { WsHandlers } from '@/lib/ws/handlers/types';
import type { PermissionRequestedPayload } from '@/lib/types/backend';
export function registerPermissionsHandlers(store: StoreApi<AppState>): WsHandlers {
return {
'permission.requested': (message) => {
const payload = message.payload as PermissionRequestedPayload;
const state = store.getState();
// Convert to PendingPermission type (they're compatible)
const permission: PendingPermission = {
pending_id: payload.pending_id,
task_id: payload.task_id,
agent_instance_id: payload.agent_instance_id,
session_id: payload.session_id,
tool_call_id: payload.tool_call_id,
title: payload.title,
options: payload.options,
action_type: payload.action_type,
action_details: payload.action_details,
created_at: payload.created_at,
timestamp: payload.timestamp,
};
state.addPendingPermission(permission);
},
};
}
-2
View File
@@ -11,7 +11,6 @@ import { registerEnvironmentsHandlers } from '@/lib/ws/handlers/environments';
import { registerExecutorsHandlers } from '@/lib/ws/handlers/executors';
import { registerGitStatusHandlers } from '@/lib/ws/handlers/git-status';
import { registerKanbanHandlers } from '@/lib/ws/handlers/kanban';
import { registerPermissionsHandlers } from '@/lib/ws/handlers/permissions';
import { registerSystemEventsHandlers } from '@/lib/ws/handlers/system-events';
import { registerTasksHandlers } from '@/lib/ws/handlers/tasks';
import { registerTerminalsHandlers } from '@/lib/ws/handlers/terminals';
@@ -35,7 +34,6 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
...registerMessagesHandlers(store),
...registerNotificationsHandlers(store),
...registerGitStatusHandlers(store),
...registerPermissionsHandlers(store),
...registerSystemEventsHandlers(store),
};
}