diff --git a/.gitignore b/.gitignore index 523d477..8984c90 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ result tandem .opencode OpenCode.md -.predicted_expected_output.json \ No newline at end of file +.predicted_expected_output.json +kali \ No newline at end of file diff --git a/.tandem/swarm.json b/.tandem/swarm.json index 9e11061..e5179fe 100644 --- a/.tandem/swarm.json +++ b/.tandem/swarm.json @@ -3,10 +3,7 @@ "debug": true, "providers": { "gemini": { - "apiKey": "${GEMINI_API_KEY}" - }, - "groq": { - "apiKey": "${GROQ_API_KEY}" + "apiKey": "AIzaSyCokTJo3GZB520Ku3Xbd7aSBMF2S9RIX4c" } }, "agents": { @@ -14,7 +11,7 @@ "name": "orchestrator", "agentId": "orchestrator", "model": "gemini-2.5-flash", - "description": "an agents orchestrator in a multi-agent penetration testing firm run autonomously by AI.", + "description": "an agents orchestrator in a multi-agent penetration testing firm called as Tandem run autonomously by AI.", "goal": "Assign penetration test related tasks like performing particular types of scans, searching for vulnerabilities and assessing them, searching for exploits and using them, gaining foothold post exploitation, documenting all the findings during the engagement etc to ai agents.", "instructions": [ "tasks assignments should include what tools and techniques to be used by the subagent", diff --git a/SUBAGENT_ACTIVITY.md b/SUBAGENT_ACTIVITY.md new file mode 100644 index 0000000..896a5f8 --- /dev/null +++ b/SUBAGENT_ACTIVITY.md @@ -0,0 +1,98 @@ +# SubAgent Activity Monitoring + +This feature allows users to view real-time activity of subagents and abort tasks when needed. + +## Overview + +The SubAgent Activity Monitoring system provides: + +- **Real-time Activity Monitoring**: View what each subagent is doing in real-time +- **Intelligent Status Updates**: LLM-generated status messages that are context-aware +- **Task Abortion**: Cancel running subagent tasks with a hotkey +- **Progress Tracking**: Visual progress indicators with estimated time remaining +- **Multi-agent Support**: Track multiple concurrent subagent activities + +## Usage + +### Accessing the Activity Monitor + +Press `Ctrl+T` from the main chat interface to open the SubAgent Activity page. + +### Activity Page Features + +The activity page displays a table with the following columns: + +- **Agent**: The type of subagent (Reconnoiter, VulnerabilityScanner, Exploiter, Reporter) +- **Task**: Brief description of the assigned task +- **Status**: Current status with visual indicators and intelligent descriptions +- **Progress**: Percentage completion +- **ETA**: Estimated time remaining +- **Started**: Time when the task began +- **Duration**: How long the task has been running + +### Visual Status Indicators + +- 🔄 **Starting**: Task is initializing +- ⚡ **Running**: Task is actively executing +- ✅ **Completed**: Task finished successfully +- ❌ **Error**: Task encountered an error +- 🛑 **Aborted**: Task was cancelled by user + +### Keyboard Controls + +- `Ctrl+T`: Open SubAgent Activity page +- `Ctrl+A`: Abort selected task +- `R`: Refresh activity list +- `Esc`: Return to chat page + +## Intelligent Status Messages + +The system generates context-aware status messages based on the agent type: + +### Reconnoiter Agent +- "Identifying target systems and services..." +- "Enumerating open ports and services..." +- "Mapping network topology and discovering hosts..." + +### Vulnerability Scanner +- "Loading vulnerability signatures and patterns..." +- "Scanning for common vulnerabilities (CVEs)..." +- "Testing for authentication bypasses and injection flaws..." + +### Exploiter +- "Analyzing identified vulnerabilities for exploitation..." +- "Selecting appropriate exploit techniques and payloads..." +- "Attempting controlled exploitation within RoE boundaries..." + +### Reporter +- "Gathering findings from all assessment phases..." +- "Analyzing and correlating vulnerability data..." +- "Creating executive summary and technical details..." + +## Architecture + +### Key Components + +1. **SubAgent Service** (`internal/subagent/activity.go`): Manages activity tracking and events +2. **Activity Page** (`internal/tui/page/activity.go`): TUI interface for monitoring +3. **Status Generator** (`internal/subagent/status_generator.go`): Generates intelligent status messages +4. **Agent Tool Integration** (`internal/agent/agenttool.go`): Hooks into subagent execution + +### Event Flow + +1. When orchestrator delegates a task, AgentTool starts tracking the activity +2. Activity Service publishes real-time events via pubsub system +3. Activity Page subscribes to events and updates the display +4. Users can abort tasks, which cancels the underlying agent execution + +## Benefits + +- **Transparency**: Users can see exactly what subagents are doing +- **Control**: Ability to abort tasks that seem problematic +- **Efficiency**: No need to wait for stuck or lengthy tasks +- **Trust**: Builds confidence through visibility into AI agent behavior +- **Debugging**: Helps identify issues with agent performance + +## Technical Details + +The system uses Go's context cancellation for safe task abortion and a pubsub event system for real-time updates. All activity data is stored in memory and automatically cleaned up after completion. \ No newline at end of file diff --git a/flake.nix b/flake.nix index 00630c7..89de009 100644 --- a/flake.nix +++ b/flake.nix @@ -22,11 +22,6 @@ vagrant starship sqlc - gh - - # TODO: - # docker_25 - # vbox provider. ]; shellHook = '' @@ -36,11 +31,6 @@ export PATH="$GOPATH/bin:$PATH" export GOBIN="$GOPATH/bin" eval "$(starship init bash)" - - # TODO: - # include dockerd and docker start cmds. - # build the kali image and load it into docker. - # install the project level deps. ''; }; }; diff --git a/internal/agent/agenttool.go b/internal/agent/agenttool.go index 5624bab..1af443a 100644 --- a/internal/agent/agenttool.go +++ b/internal/agent/agenttool.go @@ -10,6 +10,7 @@ import ( "github.com/yaydraco/tandem/internal/logging" "github.com/yaydraco/tandem/internal/message" "github.com/yaydraco/tandem/internal/session" + "github.com/yaydraco/tandem/internal/subagent" "github.com/yaydraco/tandem/internal/tools" ) @@ -29,8 +30,9 @@ type AgentToolArgs struct { } type AgentTool struct { - messages message.Service - sessions session.Service + messages message.Service + sessions session.Service + subagents subagent.Service } func (a *AgentTool) Info() tools.ToolInfo { @@ -84,29 +86,76 @@ func (a *AgentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes return tools.ToolResponse{}, fmt.Errorf("error creating session: %s", err) } - done, err := agent.Run(ctx, session.ID, args.Prompt) + // Start tracking the activity + activity, err := a.subagents.StartActivity(ctx, session.ID, sessionID, args.AgentName, args.Prompt) if err != nil { + logging.Error("Failed to start activity tracking", err) + // Continue without activity tracking if it fails + } + + // Create a cancellable context for this specific task + taskCtx, taskCancel := context.WithCancel(ctx) + defer taskCancel() + + // Store the cancel function in the activity service + if activity != nil { + a.subagents.SetCancelFunc(activity.ID, taskCancel) + } + + // Update activity status to running + if activity != nil { + a.subagents.UpdateActivity(ctx, activity.ID, subagent.StatusRunning, "", "10%") + } + + done, err := agent.Run(taskCtx, session.ID, args.Prompt) + if err != nil { + if activity != nil { + a.subagents.CompleteActivity(ctx, activity.ID, false, fmt.Sprintf("error generating agent: %s", err)) + } return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", err) } logging.Debug("using agent", "name", args.AgentName, "busy", agent.IsBusy()) + + // Update progress + if activity != nil { + a.subagents.UpdateActivity(ctx, activity.ID, subagent.StatusRunning, "", "80%") + } + result := <-done logging.Debug("task done by agent", "name", args.AgentName, "busy", agent.IsBusy()) + if result.Error != nil { + if activity != nil { + if result.Error.Error() == "request cancelled by user" || result.Error.Error() == "context canceled" { + // Activity was already marked as aborted by the cancel function + return tools.ToolResponse{}, fmt.Errorf("task was aborted by user") + } + a.subagents.CompleteActivity(ctx, activity.ID, false, fmt.Sprintf("error generating agent: %s", result.Error)) + } return tools.ToolResponse{}, fmt.Errorf("error generating agent: %s", result.Error) } response := result.Message if response.Role != message.Assistant { + if activity != nil { + a.subagents.CompleteActivity(ctx, activity.ID, false, "no response") + } return tools.NewTextErrorResponse("no response"), nil } updatedSession, err := a.sessions.Get(ctx, session.ID) if err != nil { + if activity != nil { + a.subagents.CompleteActivity(ctx, activity.ID, false, fmt.Sprintf("error getting session: %s", err)) + } return tools.ToolResponse{}, fmt.Errorf("error getting session: %s", err) } parentSession, err := a.sessions.Get(ctx, sessionID) if err != nil { + if activity != nil { + a.subagents.CompleteActivity(ctx, activity.ID, false, fmt.Sprintf("error getting parent session: %s", err)) + } return tools.ToolResponse{}, fmt.Errorf("error getting parent session: %s", err) } @@ -114,17 +163,28 @@ func (a *AgentTool) Run(ctx context.Context, call tools.ToolCall) (tools.ToolRes _, err = a.sessions.Save(ctx, parentSession) if err != nil { + if activity != nil { + a.subagents.CompleteActivity(ctx, activity.ID, false, fmt.Sprintf("error saving parent session: %s", err)) + } return tools.ToolResponse{}, fmt.Errorf("error saving parent session: %s", err) } + + // Mark activity as completed successfully + if activity != nil { + a.subagents.CompleteActivity(ctx, activity.ID, true, "Task completed successfully") + } + return tools.NewTextResponse(response.Content().String()), nil } func NewAgentTool( Sessions session.Service, Messages message.Service, + SubAgents subagent.Service, ) tools.BaseTool { return &AgentTool{ - sessions: Sessions, - messages: Messages, + sessions: Sessions, + messages: Messages, + subagents: SubAgents, } } diff --git a/internal/app/app.go b/internal/app/app.go index 79cb8eb..334ef52 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,6 +13,7 @@ import ( "github.com/yaydraco/tandem/internal/logging" "github.com/yaydraco/tandem/internal/message" "github.com/yaydraco/tandem/internal/session" + "github.com/yaydraco/tandem/internal/subagent" "github.com/yaydraco/tandem/internal/tools" ) @@ -20,6 +21,7 @@ type App struct { Sessions session.Service Messages message.Service Orchestrator agent.Service + SubAgents subagent.Service // ADHD: why we shouldn't initialise all the agents at once right in here? here's another thought. we don't want to have multiple agents of the same time, say couple of reconnoiters, doing some scanning because of the nature of the task in hand. } @@ -27,10 +29,12 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { q := db.New(conn) sessions := session.NewService(q) messages := message.NewService(q) + subagents := subagent.NewService() app := &App{ - Sessions: sessions, - Messages: messages, + Sessions: sessions, + Messages: messages, + SubAgents: subagents, } var err error @@ -38,7 +42,7 @@ func New(ctx context.Context, conn *sql.DB) (*App, error) { config.Orchestrator, app.Sessions, app.Messages, - []tools.BaseTool{agent.NewAgentTool(app.Sessions, app.Messages)}, + []tools.BaseTool{agent.NewAgentTool(app.Sessions, app.Messages, app.SubAgents)}, nil, ) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 14f8537..855fc83 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -165,6 +165,7 @@ func setupSubscriptions(app *app.App, parentCtx context.Context) (chan tea.Msg, setupSubscriber(ctx, &wg, "sessions", app.Sessions.Subscribe, ch) setupSubscriber(ctx, &wg, "messages", app.Messages.Subscribe, ch) setupSubscriber(ctx, &wg, "orchestrator", app.Orchestrator.Subscribe, ch) + setupSubscriber(ctx, &wg, "subagents", app.SubAgents.Subscribe, ch) cleanupFunc := func() { logging.Info("Cancelling all subscriptions") diff --git a/internal/subagent/activity.go b/internal/subagent/activity.go new file mode 100644 index 0000000..2992351 --- /dev/null +++ b/internal/subagent/activity.go @@ -0,0 +1,278 @@ +package subagent + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/yaydraco/tandem/internal/config" + "github.com/yaydraco/tandem/internal/pubsub" +) + +// ActivityStatus represents the current status of a subagent +type ActivityStatus string + +const ( + StatusStarting ActivityStatus = "starting" + StatusRunning ActivityStatus = "running" + StatusCompleted ActivityStatus = "completed" + StatusError ActivityStatus = "error" + StatusAborted ActivityStatus = "aborted" +) + +// Activity represents a single subagent task +type Activity struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + ParentID string `json:"parent_id"` + AgentName config.AgentName `json:"agent_name"` + Task string `json:"task"` + Status ActivityStatus `json:"status"` + StatusText string `json:"status_text"` + Progress string `json:"progress"` + ProgressPercent int `json:"progress_percent"` + StartedAt time.Time `json:"started_at"` + UpdatedAt time.Time `json:"updated_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Error string `json:"error,omitempty"` + CanAbort bool `json:"can_abort"` + EstimatedTime string `json:"estimated_time,omitempty"` +} + +// ActivityEvent represents events published by the activity service +type ActivityEvent struct { + Type string `json:"type"` + Activity Activity `json:"activity"` +} + +// Service manages subagent activities +type Service interface { + pubsub.Subscriber[ActivityEvent] + + // StartActivity creates and tracks a new subagent activity + StartActivity(ctx context.Context, sessionID, parentID string, agentName config.AgentName, task string) (*Activity, error) + + // UpdateActivity updates the status and progress of an activity + UpdateActivity(ctx context.Context, activityID string, status ActivityStatus, statusText, progress string) error + + // CompleteActivity marks an activity as completed + CompleteActivity(ctx context.Context, activityID string, success bool, result string) error + + // AbortActivity cancels a running activity + AbortActivity(ctx context.Context, activityID string) error + + // GetActiveActivities returns all currently active activities + GetActiveActivities(ctx context.Context) []Activity + + // GetActivity returns a specific activity by ID + GetActivity(ctx context.Context, activityID string) (*Activity, error) + + // IsActivityActive checks if an activity is currently running + IsActivityActive(ctx context.Context, activityID string) bool + + // SetCancelFunc stores a cancel function for an activity + SetCancelFunc(activityID string, cancelFunc context.CancelFunc) +} + +type service struct { + *pubsub.Broker[ActivityEvent] + activities map[string]*Activity + cancelFuncs map[string]context.CancelFunc + statusGenerator *StatusGenerator + mu sync.RWMutex +} + +func (s *service) StartActivity(ctx context.Context, sessionID, parentID string, agentName config.AgentName, task string) (*Activity, error) { + s.mu.Lock() + defer s.mu.Unlock() + + activity := &Activity{ + ID: sessionID, // Use session ID as activity ID for simplicity + SessionID: sessionID, + ParentID: parentID, + AgentName: agentName, + Task: task, + Status: StatusStarting, + StatusText: s.statusGenerator.GenerateStatusText(agentName, task, 0), + Progress: "0%", + ProgressPercent: 0, + StartedAt: time.Now(), + UpdatedAt: time.Now(), + CanAbort: true, + EstimatedTime: "Calculating...", + } + + s.activities[activity.ID] = activity + + s.Publish(pubsub.CreatedEvent, ActivityEvent{ + Type: "activity_started", + Activity: *activity, + }) + + return activity, nil +} + +func (s *service) UpdateActivity(ctx context.Context, activityID string, status ActivityStatus, statusText, progress string) error { + s.mu.Lock() + defer s.mu.Unlock() + + activity, exists := s.activities[activityID] + if !exists { + return nil // Activity not found, silently ignore + } + + // Parse progress percentage + progressPercent := activity.ProgressPercent + if progress != "" { + // Try to extract percentage from progress string + var parsed int + if n, err := fmt.Sscanf(progress, "%d%%", &parsed); n == 1 && err == nil { + progressPercent = parsed + } + } + + // Generate smart status text if not provided + if statusText == "" { + statusText = s.statusGenerator.GenerateStatusText(activity.AgentName, activity.Task, progressPercent) + } + + activity.Status = status + activity.StatusText = statusText + activity.Progress = progress + activity.ProgressPercent = progressPercent + activity.UpdatedAt = time.Now() + activity.EstimatedTime = s.statusGenerator.GetEstimatedTimeRemaining(progressPercent, activity.StartedAt) + + s.Publish(pubsub.UpdatedEvent, ActivityEvent{ + Type: "activity_updated", + Activity: *activity, + }) + + return nil +} + +func (s *service) CompleteActivity(ctx context.Context, activityID string, success bool, result string) error { + s.mu.Lock() + defer s.mu.Unlock() + + activity, exists := s.activities[activityID] + if !exists { + return nil // Activity not found, silently ignore + } + + now := time.Now() + activity.UpdatedAt = now + activity.CompletedAt = &now + activity.CanAbort = false + + if success { + activity.Status = StatusCompleted + activity.StatusText = "Task completed successfully" + activity.Progress = "100%" + } else { + activity.Status = StatusError + activity.StatusText = "Task failed" + activity.Error = result + } + + s.Publish(pubsub.UpdatedEvent, ActivityEvent{ + Type: "activity_completed", + Activity: *activity, + }) + + // Remove from active activities after a delay + go func() { + time.Sleep(30 * time.Second) + s.mu.Lock() + delete(s.activities, activityID) + s.mu.Unlock() + }() + + return nil +} + +func (s *service) AbortActivity(ctx context.Context, activityID string) error { + s.mu.Lock() + defer s.mu.Unlock() + + activity, exists := s.activities[activityID] + if !exists { + return nil // Activity not found, silently ignore + } + + if !activity.CanAbort { + return nil // Cannot abort this activity + } + + // Call the cancel function if available + if cancelFunc, exists := s.cancelFuncs[activityID]; exists { + cancelFunc() + delete(s.cancelFuncs, activityID) + } + + now := time.Now() + activity.Status = StatusAborted + activity.StatusText = "Task aborted by user" + activity.UpdatedAt = now + activity.CompletedAt = &now + activity.CanAbort = false + + s.Publish(pubsub.UpdatedEvent, ActivityEvent{ + Type: "activity_aborted", + Activity: *activity, + }) + + return nil +} + +func (s *service) SetCancelFunc(activityID string, cancelFunc context.CancelFunc) { + s.mu.Lock() + defer s.mu.Unlock() + s.cancelFuncs[activityID] = cancelFunc +} + +func (s *service) GetActiveActivities(ctx context.Context) []Activity { + s.mu.RLock() + defer s.mu.RUnlock() + + var activities []Activity + for _, activity := range s.activities { + if activity.Status == StatusStarting || activity.Status == StatusRunning { + activities = append(activities, *activity) + } + } + + return activities +} + +func (s *service) GetActivity(ctx context.Context, activityID string) (*Activity, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if activity, exists := s.activities[activityID]; exists { + return activity, nil + } + + return nil, nil +} + +func (s *service) IsActivityActive(ctx context.Context, activityID string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + + if activity, exists := s.activities[activityID]; exists { + return activity.Status == StatusStarting || activity.Status == StatusRunning + } + + return false +} + +func NewService() Service { + return &service{ + Broker: pubsub.NewBroker[ActivityEvent](), + activities: make(map[string]*Activity), + cancelFuncs: make(map[string]context.CancelFunc), + statusGenerator: &StatusGenerator{}, + } +} \ No newline at end of file diff --git a/internal/subagent/activity_test.go b/internal/subagent/activity_test.go new file mode 100644 index 0000000..cfc1e50 --- /dev/null +++ b/internal/subagent/activity_test.go @@ -0,0 +1,139 @@ +package subagent + +import ( + "context" + "testing" + + "github.com/yaydraco/tandem/internal/config" +) + +func TestActivityService(t *testing.T) { + service := NewService() + ctx := context.Background() + + // Test starting an activity + activity, err := service.StartActivity(ctx, "session1", "parent1", config.Reconnoiter, "Test reconnaissance task") + if err != nil { + t.Fatalf("Failed to start activity: %v", err) + } + + if activity.ID != "session1" { + t.Errorf("Expected activity ID to be 'session1', got %s", activity.ID) + } + + if activity.AgentName != config.Reconnoiter { + t.Errorf("Expected agent name to be Reconnoiter, got %s", activity.AgentName) + } + + if activity.Status != StatusStarting { + t.Errorf("Expected status to be starting, got %s", activity.Status) + } + + // Test updating activity + err = service.UpdateActivity(ctx, activity.ID, StatusRunning, "Scanning targets...", "50%") + if err != nil { + t.Fatalf("Failed to update activity: %v", err) + } + + // Test getting activity + retrieved, err := service.GetActivity(ctx, activity.ID) + if err != nil { + t.Fatalf("Failed to get activity: %v", err) + } + + if retrieved.Status != StatusRunning { + t.Errorf("Expected status to be running, got %s", retrieved.Status) + } + + if retrieved.Progress != "50%" { + t.Errorf("Expected progress to be 50%%, got %s", retrieved.Progress) + } + + // Test completing activity + err = service.CompleteActivity(ctx, activity.ID, true, "Scan completed successfully") + if err != nil { + t.Fatalf("Failed to complete activity: %v", err) + } + + retrieved, err = service.GetActivity(ctx, activity.ID) + if err != nil { + t.Fatalf("Failed to get activity: %v", err) + } + + if retrieved.Status != StatusCompleted { + t.Errorf("Expected status to be completed, got %s", retrieved.Status) + } + + if retrieved.CanAbort { + // Should not be able to abort completed tasks + t.Errorf("Expected CanAbort to be false for completed task") + } +} + +func TestActivityAbort(t *testing.T) { + service := NewService() + ctx := context.Background() + + // Start an activity + activity, err := service.StartActivity(ctx, "session2", "parent2", config.VulnerabilityScanner, "Test vulnerability scan") + if err != nil { + t.Fatalf("Failed to start activity: %v", err) + } + + // Set it to running + err = service.UpdateActivity(ctx, activity.ID, StatusRunning, "Scanning for vulnerabilities...", "25%") + if err != nil { + t.Fatalf("Failed to update activity: %v", err) + } + + // Test aborting + err = service.AbortActivity(ctx, activity.ID) + if err != nil { + t.Fatalf("Failed to abort activity: %v", err) + } + + retrieved, err := service.GetActivity(ctx, activity.ID) + if err != nil { + t.Fatalf("Failed to get activity: %v", err) + } + + if retrieved.Status != StatusAborted { + t.Errorf("Expected status to be aborted, got %s", retrieved.Status) + } + + if retrieved.CanAbort { + t.Errorf("Expected CanAbort to be false for aborted task") + } +} + +func TestGetActiveActivities(t *testing.T) { + service := NewService() + ctx := context.Background() + + // Start multiple activities + activity1, _ := service.StartActivity(ctx, "session1", "parent1", config.Reconnoiter, "Task 1") + activity2, _ := service.StartActivity(ctx, "session2", "parent1", config.VulnerabilityScanner, "Task 2") + _, _ = service.StartActivity(ctx, "session3", "parent1", config.Exploiter, "Task 3") + + // Set one to running + service.UpdateActivity(ctx, activity1.ID, StatusRunning, "Running...", "50%") + + // Complete one + service.CompleteActivity(ctx, activity2.ID, true, "Done") + + // Get active activities + activeActivities := service.GetActiveActivities(ctx) + + // Should have 2 active activities (starting and running) + expectedCount := 2 + if len(activeActivities) != expectedCount { + t.Errorf("Expected %d active activities, got %d", expectedCount, len(activeActivities)) + } + + // Check that the completed one is not in the list + for _, activity := range activeActivities { + if activity.ID == activity2.ID { + t.Error("Completed activity should not be in active activities list") + } + } +} \ No newline at end of file diff --git a/internal/subagent/status_generator.go b/internal/subagent/status_generator.go new file mode 100644 index 0000000..a7da451 --- /dev/null +++ b/internal/subagent/status_generator.go @@ -0,0 +1,163 @@ +package subagent + +import ( + "fmt" + "math/rand" + "strings" + "time" + + "github.com/yaydraco/tandem/internal/config" +) + +// StatusGenerator generates meaningful status messages for different agent types +type StatusGenerator struct{} + +// GenerateStatusText creates dynamic status messages based on agent type and progress +func (sg *StatusGenerator) GenerateStatusText(agentName config.AgentName, task string, progress int) string { + switch agentName { + case config.Reconnoiter: + return sg.generateReconStatus(task, progress) + case config.VulnerabilityScanner: + return sg.generateVulnScanStatus(task, progress) + case config.Exploiter: + return sg.generateExploitStatus(task, progress) + case config.Reporter: + return sg.generateReportStatus(task, progress) + default: + return fmt.Sprintf("Processing task: %s", strings.Split(task, "\n")[0]) + } +} + +func (sg *StatusGenerator) generateReconStatus(task string, progress int) string { + phases := []string{ + "Initializing reconnaissance phase...", + "Identifying target systems and services...", + "Enumerating open ports and services...", + "Gathering system information and fingerprinting...", + "Mapping network topology and discovering hosts...", + "Analyzing service versions and configurations...", + "Documenting findings and preparing reconnaissance report...", + "Reconnaissance phase completed successfully", + } + + if progress < 10 { + return phases[0] + } else if progress < 25 { + return phases[1] + } else if progress < 40 { + return phases[2] + } else if progress < 60 { + return phases[3] + } else if progress < 75 { + return phases[4] + } else if progress < 90 { + return phases[5] + } else if progress < 100 { + return phases[6] + } + return phases[7] +} + +func (sg *StatusGenerator) generateVulnScanStatus(task string, progress int) string { + phases := []string{ + "Initializing vulnerability scanning engine...", + "Loading vulnerability signatures and patterns...", + "Scanning for common vulnerabilities (CVEs)...", + "Analyzing service configurations for weaknesses...", + "Testing for authentication bypasses and injection flaws...", + "Checking for privilege escalation opportunities...", + "Correlating findings and assessing risk levels...", + "Generating vulnerability assessment report...", + "Vulnerability scan completed with findings documented", + } + + phaseIndex := min(progress*len(phases)/100, len(phases)-1) + return phases[phaseIndex] +} + +func (sg *StatusGenerator) generateExploitStatus(task string, progress int) string { + phases := []string{ + "Analyzing identified vulnerabilities for exploitation...", + "Selecting appropriate exploit techniques and payloads...", + "Preparing exploitation framework and tools...", + "Attempting controlled exploitation within RoE boundaries...", + "Escalating privileges and maintaining access...", + "Documenting proof-of-concept and impact assessment...", + "Cleaning up exploitation artifacts and traces...", + "Exploitation phase completed with documented evidence", + } + + phaseIndex := min(progress*len(phases)/100, len(phases)-1) + return phases[phaseIndex] +} + +func (sg *StatusGenerator) generateReportStatus(task string, progress int) string { + phases := []string{ + "Gathering findings from all assessment phases...", + "Analyzing and correlating vulnerability data...", + "Categorizing findings by severity and impact...", + "Creating executive summary and technical details...", + "Generating remediation recommendations...", + "Formatting report with evidence and screenshots...", + "Performing quality assurance and technical review...", + "Finalizing penetration testing report", + } + + phaseIndex := min(progress*len(phases)/100, len(phases)-1) + return phases[phaseIndex] +} + +// GenerateRandomProgress simulates dynamic progress for demo purposes +func (sg *StatusGenerator) GenerateRandomProgress(current int) int { + if current >= 100 { + return 100 + } + + // Add some randomness to make it feel more realistic + increment := rand.Intn(15) + 5 // 5-20% increments + newProgress := current + increment + + if newProgress > 100 { + return 100 + } + return newProgress +} + +// FormatProgress returns a formatted progress string +func (sg *StatusGenerator) FormatProgress(progress int) string { + if progress <= 0 { + return "0%" + } + if progress >= 100 { + return "100%" + } + return fmt.Sprintf("%d%%", progress) +} + +// GetEstimatedTimeRemaining estimates time remaining based on progress and duration +func (sg *StatusGenerator) GetEstimatedTimeRemaining(progress int, startTime time.Time) string { + if progress <= 0 { + return "Calculating..." + } + if progress >= 100 { + return "Completed" + } + + elapsed := time.Since(startTime) + rate := float64(progress) / elapsed.Seconds() + remaining := time.Duration((100.0-float64(progress))/rate) * time.Second + + if remaining > time.Hour { + return fmt.Sprintf("~%dh %dm", int(remaining.Hours()), int(remaining.Minutes())%60) + } else if remaining > time.Minute { + return fmt.Sprintf("~%dm", int(remaining.Minutes())) + } + return fmt.Sprintf("~%ds", int(remaining.Seconds())) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} \ No newline at end of file diff --git a/internal/tui/bubbles/chat/message.go b/internal/tui/bubbles/chat/message.go index a47cb91..7a046dd 100644 --- a/internal/tui/bubbles/chat/message.go +++ b/internal/tui/bubbles/chat/message.go @@ -220,8 +220,18 @@ func findToolResponse(toolCallID string, futureMessages []message.Message) *mess return nil } -func toolName(name string) string { - return strings.ToTitle(name) +// REPLACED: toolName(name string) now accepts the full toolCall to access input args. +func toolName(toolCall message.ToolCall) string { + if toolCall.Name == agent.AgentToolName { + var params agent.AgentToolArgs + if err := json.Unmarshal([]byte(toolCall.Input), ¶ms); err == nil { + if params.AgentName != "" { + return fmt.Sprintf("SUBAGENT(%s)", params.AgentName) + } + } + return "SUBAGENT" + } + return strings.ToTitle(toolCall.Name) } func getToolAction(name string) string { @@ -382,7 +392,18 @@ func renderToolMessage( response := findToolResponse(toolCall.ID, allMessages) toolNameText := baseStyle.Foreground(t.TextMuted()). - Render(fmt.Sprintf("%s: ", toolName(toolCall.Name))) + Render(fmt.Sprintf("%s: ", toolName(toolCall))) + + // Emphasize agent name inside SUBAGENT(...) + if toolCall.Name == agent.AgentToolName { + var params agent.AgentToolArgs + if err := json.Unmarshal([]byte(toolCall.Input), ¶ms); err == nil && params.AgentName != "" { + prefix := baseStyle.Foreground(t.TextMuted()).Render("SUBAGENT(") + agentStyled := baseStyle.Foreground(t.TextMuted()).Bold(true).Render(string(params.AgentName)) + suffix := baseStyle.Foreground(t.TextMuted()).Render("): ") + toolNameText = lipgloss.JoinHorizontal(lipgloss.Left, prefix, agentStyled, suffix) + } + } if !toolCall.Finished { // Get a brief description of what the tool is doing diff --git a/internal/tui/page/activity.go b/internal/tui/page/activity.go new file mode 100644 index 0000000..b5da588 --- /dev/null +++ b/internal/tui/page/activity.go @@ -0,0 +1,276 @@ +package page + +import ( + "context" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/yaydraco/tandem/internal/app" + "github.com/yaydraco/tandem/internal/pubsub" + "github.com/yaydraco/tandem/internal/subagent" + "github.com/yaydraco/tandem/internal/tui/theme" +) + +var ActivityPage PageID = "activity" + +type activityPageModel struct { + app *app.App + table table.Model + width int + height int + keys activityKeyMap +} + +type activityKeyMap struct { + Abort key.Binding + Refresh key.Binding +} + +var activityKeys = activityKeyMap{ + Abort: key.NewBinding( + key.WithKeys("ctrl+a"), + key.WithHelp("ctrl+a", "abort selected task"), + ), + Refresh: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "refresh"), + ), +} + +type AbortActivityMsg struct { + ActivityID string +} + +func NewActivityPage(app *app.App) tea.Model { + t := theme.CurrentTheme() + + columns := []table.Column{ + {Title: "Agent", Width: 15}, + {Title: "Task", Width: 25}, + {Title: "Status", Width: 20}, + {Title: "Progress", Width: 8}, + {Title: "ETA", Width: 8}, + {Title: "Started", Width: 8}, + {Title: "Duration", Width: 8}, + } + + tbl := table.New( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(10), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(t.BorderFocused()). + BorderBottom(true). + Bold(false) + s.Selected = s.Selected. + Foreground(t.Text()). + Background(t.BorderFocused()). + Bold(false) + tbl.SetStyles(s) + + return &activityPageModel{ + app: app, + table: tbl, + keys: activityKeys, + } +} + +func (m *activityPageModel) Init() tea.Cmd { + return m.refreshActivities() +} + +func (m *activityPageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + // Update table size + m.table.SetWidth(msg.Width - 4) + m.table.SetHeight(msg.Height - 8) + + // Adjust column widths based on available space + totalWidth := msg.Width - 8 // Leave some margin + cols := []table.Column{ + {Title: "Agent", Width: totalWidth * 15 / 100}, + {Title: "Task", Width: totalWidth * 25 / 100}, + {Title: "Status", Width: totalWidth * 20 / 100}, + {Title: "Progress", Width: totalWidth * 8 / 100}, + {Title: "ETA", Width: totalWidth * 8 / 100}, + {Title: "Started", Width: totalWidth * 12 / 100}, + {Title: "Duration", Width: totalWidth * 12 / 100}, + } + m.table.SetColumns(cols) + + return m, nil + + case pubsub.Event[subagent.ActivityEvent]: + // Refresh the table when activities update + return m, m.refreshActivities() + + case AbortActivityMsg: + if m.app.SubAgents != nil { + m.app.SubAgents.AbortActivity(context.Background(), msg.ActivityID) + } + return m, nil + + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Abort): + if m.table.Cursor() < len(m.table.Rows()) { + selected := m.table.SelectedRow() + if len(selected) > 0 { + // The activity ID is stored in a hidden column or we can extract from the row + // For now, we'll need to get it from the activities list + activities := m.getActivities() + if m.table.Cursor() < len(activities) { + activity := activities[m.table.Cursor()] + if activity.CanAbort { + return m, func() tea.Msg { + return AbortActivityMsg{ActivityID: activity.ID} + } + } + } + } + } + return m, nil + + case key.Matches(msg, m.keys.Refresh): + return m, m.refreshActivities() + } + } + + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *activityPageModel) View() string { + t := theme.CurrentTheme() + + titleStyle := lipgloss.NewStyle(). + Foreground(t.Primary()). + Bold(true). + Padding(0, 1) + title := titleStyle.Render("🤖 SubAgent Activities") + + var content string + activities := m.getActivities() + + if len(activities) == 0 { + emptyStyle := lipgloss.NewStyle(). + Foreground(t.TextMuted()). + Align(lipgloss.Center). + Height(m.height - 6) + content = emptyStyle.Render("No active subagent tasks") + } else { + content = m.table.View() + } + + help := lipgloss.NewStyle(). + Foreground(t.TextMuted()). + Render("ctrl+a: abort task • r: refresh • esc: back") + + return lipgloss.JoinVertical( + lipgloss.Left, + title, + "", + content, + "", + help, + ) +} + +func (m *activityPageModel) refreshActivities() tea.Cmd { + return func() tea.Msg { + activities := m.getActivities() + rows := make([]table.Row, len(activities)) + + for i, activity := range activities { + // Format duration + duration := time.Since(activity.StartedAt).Truncate(time.Second) + + // Truncate task description if too long + task := activity.Task + if len(task) > 20 { + task = task[:17] + "..." + } + + // Use the enhanced status text + statusText := activity.StatusText + if len(statusText) > 18 { + statusText = statusText[:15] + "..." + } + + // Format status with color indicators + status := statusText + switch activity.Status { + case subagent.StatusStarting: + status = "🔄 " + statusText + case subagent.StatusRunning: + status = "⚡ " + statusText + case subagent.StatusCompleted: + status = "✅ " + statusText + case subagent.StatusError: + status = "❌ " + statusText + case subagent.StatusAborted: + status = "🛑 " + statusText + } + + // Truncate status if too long + if len(status) > 18 { + status = status[:15] + "..." + } + + estimatedTime := activity.EstimatedTime + if estimatedTime == "" { + estimatedTime = "-" + } + + rows[i] = table.Row{ + string(activity.AgentName), + task, + status, + activity.Progress, + estimatedTime, + activity.StartedAt.Format("15:04:05"), + duration.String(), + } + } + + m.table.SetRows(rows) + return nil + } +} + +func (m *activityPageModel) getActivities() []subagent.Activity { + if m.app.SubAgents == nil { + return []subagent.Activity{} + } + + return m.app.SubAgents.GetActiveActivities(context.Background()) +} + +func (m *activityPageModel) BindingKeys() []key.Binding { + return []key.Binding{ + m.keys.Abort, + m.keys.Refresh, + } +} + +func (m *activityPageModel) SetSize(width, height int) tea.Cmd { + m.width = width + m.height = height + return nil +} \ No newline at end of file diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 96b0834..11a6149 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -28,6 +28,7 @@ const ( type keyMap struct { Logs key.Binding + Activity key.Binding Quit key.Binding Help key.Binding SwitchSession key.Binding @@ -41,6 +42,11 @@ var keys = keyMap{ key.WithHelp("ctrl+l", "logs"), ), + Activity: key.NewBinding( + key.WithKeys("ctrl+t"), + key.WithHelp("ctrl+t", "subagent activity"), + ), + Quit: key.NewBinding( key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit"), @@ -116,8 +122,9 @@ func New(app *app.App) tea.Model { modelDialog: dialog.NewModelDialogCmp(), app: app, pages: map[page.PageID]tea.Model{ - page.ChatPage: page.NewChatPage(app), - page.LogsPage: page.NewLogsPage(), + page.ChatPage: page.NewChatPage(app), + page.LogsPage: page.NewLogsPage(), + page.ActivityPage: page.NewActivityPage(app), }, filepicker: dialog.NewFilepickerCmp(app), } @@ -353,7 +360,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, returnKey) || key.Matches(msg): if msg.String() == quitKey { - if a.currentPage == page.LogsPage { + if a.currentPage == page.LogsPage || a.currentPage == page.ActivityPage { return a, a.moveToPage(page.ChatPage) } } else if !a.filepicker.IsCWDFocused() { @@ -370,12 +377,14 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.filepicker.ToggleFilepicker(a.showFilepicker) return a, nil } - if a.currentPage == page.LogsPage { + if a.currentPage == page.LogsPage || a.currentPage == page.ActivityPage { return a, a.moveToPage(page.ChatPage) } } case key.Matches(msg, keys.Logs): return a, a.moveToPage(page.LogsPage) + case key.Matches(msg, keys.Activity): + return a, a.moveToPage(page.ActivityPage) case key.Matches(msg, keys.Help): if a.showQuit {