From 4c633b6a924dbd92eb38743a0960dc869c771b70 Mon Sep 17 00:00:00 2001 From: Patrick Marsceill Date: Fri, 23 Jan 2026 16:09:48 -0500 Subject: [PATCH 1/4] feat: add bidirectional GitHub issue sync for agent input Enable agents to request user input by posting comments to the originating GitHub issue, with automatic detection and response delivery. Components: - GitHubPoller: polls GitHub issues for new comments and delivers responses to agent tmux sessions - InputMonitor: monitors tmux sessions for idle agents with question patterns and automatically posts questions to GitHub - New CLI commands: `map task input-needed` and `map task my-task` - Schema changes: GitHub source tracking fields on tasks - Proto changes: WAITING_INPUT status, new RPCs Flow: 1. Sync issues with `map task sync gh-project` (stores GitHub metadata) 2. Agent works and asks a question (detected automatically via InputMonitor or manually via `map task input-needed`) 3. Question posted to GitHub issue with bot prefix 4. User responds on GitHub 5. GitHubPoller detects response and sends to agent's tmux session 6. Agent continues working Also fixes long prompt submission by adding delay before Enter key to handle collapsed paste previews in Claude Code. Co-Authored-By: Claude Opus 4.5 --- README.md | 38 +++- internal/cli/task.go | 2 + internal/cli/task_input.go | 111 ++++++++++ internal/cli/task_sync.go | 21 +- internal/client/client.go | 34 +++ internal/daemon/github_poller.go | 265 +++++++++++++++++++++++ internal/daemon/input_monitor.go | 309 +++++++++++++++++++++++++++ internal/daemon/process.go | 16 +- internal/daemon/server.go | 165 +++++++++++++-- internal/daemon/store.go | 182 ++++++++++++++-- internal/daemon/task.go | 54 ++++- proto/map/v1/daemon.pb.go | 352 ++++++++++++++++++++++++++----- proto/map/v1/daemon.proto | 30 +++ proto/map/v1/daemon_grpc.pb.go | 76 +++++++ proto/map/v1/types.pb.go | 262 ++++++++++++++++------- proto/map/v1/types.proto | 12 ++ 16 files changed, 1749 insertions(+), 180 deletions(-) create mode 100644 internal/cli/task_input.go create mode 100644 internal/daemon/github_poller.go create mode 100644 internal/daemon/input_monitor.go diff --git a/README.md b/README.md index 9fb3227..43b55f6 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,8 @@ This creates two binaries in `bin/`: | `map task show ` | Show detailed task information | | `map task cancel ` | Cancel a pending or in-progress task | | `map task sync gh-project ` | Sync tasks from a GitHub Project | +| `map task my-task` | Show the current task for this agent (by working directory) | +| `map task input-needed ` | Request user input via GitHub issue | ## Spawning Agents @@ -257,6 +259,8 @@ MAP includes a task routing system for distributing work to agents. Tasks follow this lifecycle: `PENDING → OFFERED → ACCEPTED → IN_PROGRESS → COMPLETED/FAILED/CANCELLED` +Tasks can also enter `WAITING_INPUT` status when an agent needs user input. Once the user responds, the task returns to `IN_PROGRESS`. + ### Task Commands ```bash @@ -318,6 +322,38 @@ map task sync gh-project "My Project" --limit 5 | `--limit` | `10` | Maximum number of items to sync | | `--dry-run` | `false` | Preview without creating tasks or updating GitHub | +### Bidirectional GitHub Issue Sync + +When tasks are synced from GitHub Projects, MAP tracks the originating issue and enables bidirectional communication: + +**Automatic Input Detection:** +- The daemon monitors agent tmux sessions for signs that the agent is waiting for user input +- When detected (agent idle + question pattern in output), the question is automatically posted to the GitHub issue +- Users can respond directly on the GitHub issue +- Responses are automatically delivered back to the agent's session + +**How it works:** +1. Sync issues with `map task sync gh-project "Project"` - GitHub metadata is stored with each task +2. Agent works on the task and asks a question (detected automatically) +3. Question is posted to the GitHub issue with prefix `**My agent needs more input:**` +4. User responds on GitHub +5. Response is delivered to the agent's tmux session +6. Agent continues working + +**Manual input requests:** + +Agents can also explicitly request input: +```bash +# From within an agent session, request user input +map task input-needed "What error format should I use?" +``` + +**Agent introspection:** +```bash +# Find the current task for this working directory +map task my-task +``` + ## Event Streaming Watch real-time events from the daemon: @@ -327,7 +363,7 @@ Watch real-time events from the daemon: map watch ``` -Events include task lifecycle changes (created, offered, accepted, started, completed, failed, cancelled) and agent status updates. +Events include task lifecycle changes (created, offered, accepted, started, completed, failed, cancelled, waiting_input, input_received) and agent status updates. ### Agent Create Options diff --git a/internal/cli/task.go b/internal/cli/task.go index 7cce19d..669080d 100644 --- a/internal/cli/task.go +++ b/internal/cli/task.go @@ -201,6 +201,8 @@ func taskStatusString(s mapv1.TaskStatus) string { return "failed" case mapv1.TaskStatus_TASK_STATUS_CANCELLED: return "cancelled" + case mapv1.TaskStatus_TASK_STATUS_WAITING_INPUT: + return "waiting_input" default: return "unknown" } diff --git a/internal/cli/task_input.go b/internal/cli/task_input.go new file mode 100644 index 0000000..63d91a6 --- /dev/null +++ b/internal/cli/task_input.go @@ -0,0 +1,111 @@ +package cli + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/pmarsceill/mapcli/internal/client" + "github.com/spf13/cobra" +) + +var taskInputNeededCmd = &cobra.Command{ + Use: "input-needed ", + Short: "Request user input for a task via GitHub issue", + Long: `Signal that an agent needs user input by posting a comment to the originating GitHub issue. + +This command: +1. Posts a comment to the GitHub issue with the question +2. Sets the task status to WAITING_INPUT +3. The daemon will poll for responses and deliver them to the agent + +The task must have originated from a GitHub issue (via 'map task sync gh-project').`, + Args: cobra.MinimumNArgs(2), + RunE: runTaskInputNeeded, +} + +var taskMyTaskCmd = &cobra.Command{ + Use: "my-task", + Short: "Show the current task for this agent", + Long: `Look up the current task by matching the working directory to an agent worktree. + +This is useful for agents to introspect their assigned task.`, + RunE: runTaskMyTask, +} + +func init() { + taskCmd.AddCommand(taskInputNeededCmd) + taskCmd.AddCommand(taskMyTaskCmd) +} + +func runTaskInputNeeded(cmd *cobra.Command, args []string) error { + taskID := args[0] + question := strings.Join(args[1:], " ") + + c, err := client.New(socketPath) + if err != nil { + return fmt.Errorf("connect to daemon: %w", err) + } + defer func() { _ = c.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + resp, err := c.RequestInput(ctx, taskID, question) + if err != nil { + return fmt.Errorf("request input: %w", err) + } + + if !resp.Success { + return fmt.Errorf("%s", resp.Message) + } + + fmt.Println(resp.Message) + return nil +} + +func runTaskMyTask(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + c, err := client.New(socketPath) + if err != nil { + return fmt.Errorf("connect to daemon: %w", err) + } + defer func() { _ = c.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + task, err := c.GetCurrentTask(ctx, cwd) + if err != nil { + return fmt.Errorf("get current task: %w", err) + } + + if task == nil { + fmt.Println("no task found for this working directory") + return nil + } + + fmt.Printf("Task ID: %s\n", task.TaskId) + fmt.Printf("Status: %s\n", taskStatusString(task.Status)) + fmt.Printf("Description: %s\n", task.Description) + fmt.Printf("Assigned To: %s\n", valueOrDash(task.AssignedTo)) + + if task.GithubSource != nil { + fmt.Printf("GitHub: %s/%s#%d\n", + task.GithubSource.Owner, + task.GithubSource.Repo, + task.GithubSource.IssueNumber) + } + + if task.WaitingInputQuestion != "" { + fmt.Printf("\nWaiting for input:\n%s\n", task.WaitingInputQuestion) + } + + return nil +} diff --git a/internal/cli/task_sync.go b/internal/cli/task_sync.go index 68338d2..83e6d24 100644 --- a/internal/cli/task_sync.go +++ b/internal/cli/task_sync.go @@ -187,9 +187,12 @@ func runTaskSyncGHProject(cmd *cobra.Command, args []string) error { // Build task description description := buildTaskDescription(item) - // Submit task + // Extract GitHub metadata from issue URL + owner, repo := parseGitHubURL(item.Content.URL) + + // Submit task with GitHub source tracking ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - task, err := c.SubmitTask(ctx, description, nil) + task, err := c.SubmitTaskWithGitHub(ctx, description, nil, owner, repo, int32(item.Content.Number)) cancel() if err != nil { @@ -199,6 +202,9 @@ func runTaskSyncGHProject(cmd *cobra.Command, args []string) error { } fmt.Printf(" Created task: %s\n", task.TaskId) + if owner != "" && repo != "" { + fmt.Printf(" GitHub source: %s/%s#%d\n", owner, repo, item.Content.Number) + } // Update item status on GitHub if err := updateItemStatus(project.ID, item.ID, statusField.ID, targetOptionID); err != nil { @@ -324,3 +330,14 @@ func updateItemStatus(projectID, itemID, fieldID, optionID string) error { return nil } + +// parseGitHubURL extracts owner and repo from a GitHub issue URL +// Example: https://github.com/pmarsceill/mapcli/issues/42 -> "pmarsceill", "mapcli" +func parseGitHubURL(url string) (owner, repo string) { + // Expected format: https://github.com/OWNER/REPO/issues/NUMBER + parts := strings.Split(url, "/") + if len(parts) >= 5 && parts[2] == "github.com" { + return parts[3], parts[4] + } + return "", "" +} diff --git a/internal/client/client.go b/internal/client/client.go index 5d994a6..e999269 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -59,6 +59,21 @@ func (c *Client) SubmitTask(ctx context.Context, description string, scopePaths return resp.Task, nil } +// SubmitTaskWithGitHub creates a new task with GitHub issue source tracking +func (c *Client) SubmitTaskWithGitHub(ctx context.Context, description string, scopePaths []string, owner, repo string, issueNumber int32) (*mapv1.Task, error) { + resp, err := c.daemon.SubmitTask(ctx, &mapv1.SubmitTaskRequest{ + Description: description, + ScopePaths: scopePaths, + GithubOwner: owner, + GithubRepo: repo, + GithubIssueNumber: issueNumber, + }) + if err != nil { + return nil, err + } + return resp.Task, nil +} + // ListTasks returns tasks with optional filters func (c *Client) ListTasks(ctx context.Context, limit int32) ([]*mapv1.Task, error) { resp, err := c.daemon.ListTasks(ctx, &mapv1.ListTasksRequest{ @@ -92,6 +107,25 @@ func (c *Client) CancelTask(ctx context.Context, taskID string) (*mapv1.Task, er return resp.Task, nil } +// RequestInput signals that an agent needs user input +func (c *Client) RequestInput(ctx context.Context, taskID, question string) (*mapv1.RequestInputResponse, error) { + return c.daemon.RequestInput(ctx, &mapv1.RequestInputRequest{ + TaskId: taskID, + Question: question, + }) +} + +// GetCurrentTask finds the task for a working directory +func (c *Client) GetCurrentTask(ctx context.Context, workingDir string) (*mapv1.Task, error) { + resp, err := c.daemon.GetCurrentTask(ctx, &mapv1.GetCurrentTaskRequest{ + WorkingDirectory: workingDir, + }) + if err != nil { + return nil, err + } + return resp.Task, nil +} + // GetStatus returns daemon status func (c *Client) GetStatus(ctx context.Context) (*mapv1.GetStatusResponse, error) { return c.daemon.GetStatus(ctx, &mapv1.GetStatusRequest{}) diff --git a/internal/daemon/github_poller.go b/internal/daemon/github_poller.go new file mode 100644 index 0000000..a84ceb8 --- /dev/null +++ b/internal/daemon/github_poller.go @@ -0,0 +1,265 @@ +package daemon + +import ( + "encoding/json" + "fmt" + "log" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + mapv1 "github.com/pmarsceill/mapcli/proto/map/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// GitHubPoller polls GitHub issues for new comments and delivers them to agents +type GitHubPoller struct { + store *Store + processes *ProcessManager + eventCh chan *mapv1.Event + + mu sync.Mutex + stop chan struct{} + interval time.Duration +} + +// ghComment represents a GitHub issue comment +type ghComment struct { + ID int `json:"id"` + Body string `json:"body"` + Author string `json:"author"` + CreatedAt string `json:"createdAt"` +} + +// ghIssueComments is the response from gh issue view --json comments +type ghIssueComments struct { + Comments []ghComment `json:"comments"` +} + +// inputRequestPrefix is the prefix we use when posting questions to GitHub +const inputRequestPrefix = "**My agent needs more input:**" + +// NewGitHubPoller creates a new GitHub poller +func NewGitHubPoller(store *Store, processes *ProcessManager, eventCh chan *mapv1.Event) *GitHubPoller { + return &GitHubPoller{ + store: store, + processes: processes, + eventCh: eventCh, + stop: make(chan struct{}), + interval: 30 * time.Second, + } +} + +// Start begins the polling loop +func (p *GitHubPoller) Start() { + go p.pollLoop() +} + +// Stop stops the polling loop +func (p *GitHubPoller) Stop() { + close(p.stop) +} + +func (p *GitHubPoller) pollLoop() { + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + + // Do an immediate poll on start + p.poll() + + for { + select { + case <-p.stop: + return + case <-ticker.C: + p.poll() + } + } +} + +func (p *GitHubPoller) poll() { + p.mu.Lock() + defer p.mu.Unlock() + + // Get all tasks waiting for input + tasks, err := p.store.ListTasksWaitingInput() + if err != nil { + log.Printf("github poller: failed to list waiting tasks: %v", err) + return + } + + for _, task := range tasks { + p.checkTaskForResponse(task) + } +} + +func (p *GitHubPoller) checkTaskForResponse(task *TaskRecord) { + // Fetch comments from GitHub + comments, err := p.fetchGitHubComments(task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber) + if err != nil { + log.Printf("github poller: failed to fetch comments for %s/%s#%d: %v", + task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, err) + return + } + + // Find new human comments (not our bot comments) since waiting_input_since + var newComment *ghComment + for i := len(comments) - 1; i >= 0; i-- { + c := &comments[i] + + // Parse comment creation time + createdAt, err := time.Parse(time.RFC3339, c.CreatedAt) + if err != nil { + continue + } + + // Skip comments before we started waiting + if createdAt.Before(task.WaitingInputSince) { + continue + } + + // Skip our own bot comments (those with the input request prefix) + if strings.HasPrefix(c.Body, inputRequestPrefix) { + continue + } + + // Skip if we've already processed this comment + if task.LastCommentID != "" { + lastID, _ := strconv.Atoi(task.LastCommentID) + if c.ID <= lastID { + continue + } + } + + // Found a new human comment + newComment = c + break + } + + if newComment == nil { + return + } + + log.Printf("github poller: found new comment on %s/%s#%d from %s", + task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, newComment.Author) + + // Deliver response to agent's tmux session + if err := p.deliverResponseToAgent(task, newComment.Body); err != nil { + log.Printf("github poller: failed to deliver response to agent: %v", err) + return + } + + // Update task status back to in_progress + if err := p.store.ClearTaskWaitingInput(task.TaskID, strconv.Itoa(newComment.ID)); err != nil { + log.Printf("github poller: failed to update task status: %v", err) + return + } + + // Emit event + p.emitInputReceivedEvent(task) + + log.Printf("github poller: delivered response to agent %s for task %s", task.AssignedTo, task.TaskID) +} + +func (p *GitHubPoller) fetchGitHubComments(owner, repo string, issueNumber int) ([]ghComment, error) { + args := []string{ + "issue", "view", strconv.Itoa(issueNumber), + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--json", "comments", + } + + out, err := exec.Command("gh", args...).Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("gh issue view failed: %s", string(exitErr.Stderr)) + } + return nil, fmt.Errorf("gh issue view failed: %w", err) + } + + var result ghIssueComments + if err := json.Unmarshal(out, &result); err != nil { + return nil, fmt.Errorf("parse comments: %w", err) + } + + return result.Comments, nil +} + +func (p *GitHubPoller) deliverResponseToAgent(task *TaskRecord, response string) error { + if task.AssignedTo == "" { + return fmt.Errorf("task has no assigned agent") + } + + tmuxSession := p.processes.GetTmuxSession(task.AssignedTo) + if tmuxSession == "" { + return fmt.Errorf("agent %s has no tmux session", task.AssignedTo) + } + + // Format the response message + message := fmt.Sprintf("User response to your question:\n\n%s", response) + + // Replace newlines for single-line tmux input + singleLineMessage := strings.ReplaceAll(message, "\n", " ") + singleLineMessage = strings.ReplaceAll(singleLineMessage, " ", " ") + + // Send to tmux session + cmd := exec.Command("tmux", "send-keys", "-t", tmuxSession, "-l", singleLineMessage) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to send response text: %w", err) + } + + // Wait for pasted text to be processed (long text shows as collapsed paste) + time.Sleep(300 * time.Millisecond) + + // Send Enter to confirm/submit + cmd = exec.Command("tmux", "send-keys", "-t", tmuxSession, "Enter") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to send Enter: %w", err) + } + + return nil +} + +func (p *GitHubPoller) emitInputReceivedEvent(task *TaskRecord) { + if p.eventCh == nil { + return + } + + event := &mapv1.Event{ + EventId: uuid.New().String(), + Type: mapv1.EventType_EVENT_TYPE_TASK_INPUT_RECEIVED, + Timestamp: timestamppb.Now(), + Payload: &mapv1.Event_Task{ + Task: &mapv1.TaskEvent{ + TaskId: task.TaskID, + NewStatus: mapv1.TaskStatus_TASK_STATUS_IN_PROGRESS, + AgentId: task.AssignedTo, + }, + }, + } + + select { + case p.eventCh <- event: + default: + } +} + +// PostQuestionToGitHub posts an input request comment to a GitHub issue +func PostQuestionToGitHub(owner, repo string, issueNumber int, question string) error { + body := fmt.Sprintf("%s %s", inputRequestPrefix, question) + + args := []string{ + "issue", "comment", strconv.Itoa(issueNumber), + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--body", body, + } + + out, err := exec.Command("gh", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("gh issue comment failed: %s: %s", err, string(out)) + } + + return nil +} diff --git a/internal/daemon/input_monitor.go b/internal/daemon/input_monitor.go new file mode 100644 index 0000000..7e8bd36 --- /dev/null +++ b/internal/daemon/input_monitor.go @@ -0,0 +1,309 @@ +package daemon + +import ( + "log" + "os/exec" + "regexp" + "strings" + "sync" + "time" + + "github.com/google/uuid" + mapv1 "github.com/pmarsceill/mapcli/proto/map/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// InputMonitor watches tmux sessions for agents waiting on user input +// and automatically posts questions to GitHub issues +type InputMonitor struct { + store *Store + processes *ProcessManager + eventCh chan *mapv1.Event + + mu sync.Mutex + stop chan struct{} + interval time.Duration + + // Track pane state to detect when agent becomes idle + lastContent map[string]string // agentID -> last captured content + lastChangeTime map[string]time.Time // agentID -> when content last changed + idleThreshold time.Duration // how long idle before considered waiting +} + +// Patterns that suggest the agent is asking a question +var questionPatterns = []*regexp.Regexp{ + // Common question endings + regexp.MustCompile(`\?\s*$`), + // Claude Code specific patterns + regexp.MustCompile(`(?i)please (choose|select|specify|confirm|provide)`), + regexp.MustCompile(`(?i)would you like`), + regexp.MustCompile(`(?i)do you want`), + regexp.MustCompile(`(?i)should I`), + regexp.MustCompile(`(?i)which (one|option)`), + regexp.MustCompile(`(?i)what (should|would)`), + // Input prompts + regexp.MustCompile(`\[Y/n\]`), + regexp.MustCompile(`\[y/N\]`), + regexp.MustCompile(`\(y/n\)`), + regexp.MustCompile(`Enter .+:`), +} + +// Patterns that indicate the agent is actively working (not waiting) +var activePatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)reading|writing|searching|analyzing|processing`), + regexp.MustCompile(`(?i)running|executing|building|compiling`), + regexp.MustCompile(`⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏`), // Spinner characters + regexp.MustCompile(`\.\.\.`), // Ellipsis indicating progress +} + +// NewInputMonitor creates a new input monitor +func NewInputMonitor(store *Store, processes *ProcessManager, eventCh chan *mapv1.Event) *InputMonitor { + return &InputMonitor{ + store: store, + processes: processes, + eventCh: eventCh, + stop: make(chan struct{}), + interval: 5 * time.Second, + lastContent: make(map[string]string), + lastChangeTime: make(map[string]time.Time), + idleThreshold: 10 * time.Second, // Consider waiting if idle for 10s with question + } +} + +// Start begins the monitoring loop +func (m *InputMonitor) Start() { + go m.monitorLoop() +} + +// Stop stops the monitoring loop +func (m *InputMonitor) Stop() { + close(m.stop) +} + +func (m *InputMonitor) monitorLoop() { + ticker := time.NewTicker(m.interval) + defer ticker.Stop() + + for { + select { + case <-m.stop: + return + case <-ticker.C: + m.checkAllAgents() + } + } +} + +func (m *InputMonitor) checkAllAgents() { + m.mu.Lock() + defer m.mu.Unlock() + + // Get all agents + agents := m.processes.List() + + for _, agent := range agents { + m.checkAgent(agent) + } +} + +func (m *InputMonitor) checkAgent(agent *AgentSlot) { + // Skip if no tmux session + if agent.TmuxSession == "" { + return + } + + // Get the task assigned to this agent + task, err := m.store.GetTaskByAgentID(agent.AgentID) + if err != nil || task == nil { + return + } + + // Skip if task doesn't have GitHub source + if task.GitHubOwner == "" || task.GitHubRepo == "" || task.GitHubIssueNumber == 0 { + return + } + + // Skip if already waiting for input + if task.Status == "waiting_input" { + return + } + + // Skip if not in progress + if task.Status != "in_progress" { + return + } + + // Capture current tmux pane content + content := m.captureTmuxContent(agent.TmuxSession) + if content == "" { + return + } + + // Track content changes + now := time.Now() + lastContent := m.lastContent[agent.AgentID] + if content != lastContent { + m.lastContent[agent.AgentID] = content + m.lastChangeTime[agent.AgentID] = now + return // Content changed, not idle yet + } + + // Check if idle long enough + lastChange, exists := m.lastChangeTime[agent.AgentID] + if !exists { + m.lastChangeTime[agent.AgentID] = now + return + } + + idleDuration := now.Sub(lastChange) + if idleDuration < m.idleThreshold { + return // Not idle long enough + } + + // Check if content suggests waiting for input + if m.isActivelyWorking(content) { + return // Agent appears to be working + } + + question := m.extractQuestion(content) + if question == "" { + return // No question detected + } + + log.Printf("input monitor: detected question from agent %s: %s", agent.AgentID, truncateLog(question, 100)) + + // Post question to GitHub + if err := PostQuestionToGitHub(task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, question); err != nil { + log.Printf("input monitor: failed to post question to GitHub: %v", err) + return + } + + // Update task status + if err := m.store.SetTaskWaitingInput(task.TaskID, question); err != nil { + log.Printf("input monitor: failed to update task status: %v", err) + return + } + + // Reset tracking for this agent + delete(m.lastContent, agent.AgentID) + delete(m.lastChangeTime, agent.AgentID) + + // Emit event + m.emitWaitingInputEvent(task, question) + + log.Printf("input monitor: posted question to %s/%s#%d for task %s", + task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, task.TaskID) +} + +func (m *InputMonitor) captureTmuxContent(session string) string { + // Capture the visible pane content + cmd := exec.Command("tmux", "capture-pane", "-t", session, "-p", "-S", "-50") + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +func (m *InputMonitor) isActivelyWorking(content string) bool { + // Check last few lines for active work patterns + lines := strings.Split(content, "\n") + lastLines := lines + if len(lines) > 10 { + lastLines = lines[len(lines)-10:] + } + recentContent := strings.Join(lastLines, "\n") + + for _, pattern := range activePatterns { + if pattern.MatchString(recentContent) { + return true + } + } + return false +} + +func (m *InputMonitor) extractQuestion(content string) string { + lines := strings.Split(content, "\n") + + // Look at the last 20 lines for a question + startIdx := 0 + if len(lines) > 20 { + startIdx = len(lines) - 20 + } + recentLines := lines[startIdx:] + + // Find lines that look like questions + var questionLines []string + foundQuestion := false + + for i := len(recentLines) - 1; i >= 0; i-- { + line := strings.TrimSpace(recentLines[i]) + if line == "" { + if foundQuestion { + break // Stop at blank line after finding question + } + continue + } + + // Check if this line matches question patterns + isQuestion := false + for _, pattern := range questionPatterns { + if pattern.MatchString(line) { + isQuestion = true + break + } + } + + if isQuestion { + foundQuestion = true + } + + if foundQuestion { + // Prepend this line (we're going backwards) + questionLines = append([]string{line}, questionLines...) + } + + // Don't go back too far + if len(questionLines) > 5 { + break + } + } + + if len(questionLines) == 0 { + return "" + } + + return strings.Join(questionLines, "\n") +} + +func (m *InputMonitor) emitWaitingInputEvent(task *TaskRecord, question string) { + if m.eventCh == nil { + return + } + + event := &mapv1.Event{ + EventId: uuid.New().String(), + Type: mapv1.EventType_EVENT_TYPE_TASK_WAITING_INPUT, + Timestamp: timestamppb.Now(), + Payload: &mapv1.Event_Task{ + Task: &mapv1.TaskEvent{ + TaskId: task.TaskID, + NewStatus: mapv1.TaskStatus_TASK_STATUS_WAITING_INPUT, + AgentId: task.AssignedTo, + }, + }, + } + + select { + case m.eventCh <- event: + default: + } +} + +func truncateLog(s string, maxLen int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) > maxLen { + return s[:maxLen] + "..." + } + return s +} diff --git a/internal/daemon/process.go b/internal/daemon/process.go index 2d88a72..fa99faa 100644 --- a/internal/daemon/process.go +++ b/internal/daemon/process.go @@ -212,8 +212,8 @@ func (m *ProcessManager) ExecuteTask(ctx context.Context, agentID string, taskID log.Printf("agent %s executing task %s via tmux", agentID, taskID) - // Build the prompt - prompt := description + // Build the prompt with task ID prefix for agent introspection + prompt := fmt.Sprintf("[Task ID: %s]\n\n%s", taskID, description) if len(scopePaths) > 0 { prompt = fmt.Sprintf("%s\n\nScope/files: %s", prompt, strings.Join(scopePaths, ", ")) } @@ -231,7 +231,12 @@ func (m *ProcessManager) ExecuteTask(ctx context.Context, agentID string, taskID return "", fmt.Errorf("failed to send task to tmux: %w", err) } - // Send Enter key to submit the prompt + // Wait for the pasted text to be processed by the terminal + // Long text may show as "[Pasted text #1 +N lines]" and need confirmation + time.Sleep(300 * time.Millisecond) + + // Send Enter key to confirm/submit the prompt + // For long pastes, this confirms the paste; for short text, this submits cmd = exec.CommandContext(ctx, "tmux", "send-keys", "-t", tmuxSession, "Enter") if err := cmd.Run(); err != nil { log.Printf("agent %s task %s failed to send Enter: %v", agentID, taskID, err) @@ -471,7 +476,10 @@ func (m *ProcessManager) Spawn(agentID, workdir, prompt, agentType string, skipP if err := cmd.Run(); err != nil { log.Printf("warning: failed to send initial prompt text to %s: %v", agentID, err) } else { - // Send Enter to submit + // Wait for pasted text to be processed (long text shows as collapsed paste) + time.Sleep(300 * time.Millisecond) + + // Send Enter to confirm/submit cmd = exec.Command("tmux", "send-keys", "-t", slot.TmuxSession, "Enter") if err := cmd.Run(); err != nil { log.Printf("warning: failed to send Enter to %s: %v", agentID, err) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 64a5866..cd18842 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -25,13 +25,15 @@ const ( type Server struct { mapv1.UnimplementedDaemonServiceServer - store *Store - tasks *TaskRouter - worktrees *WorktreeManager - processes *ProcessManager - names *NameGenerator - eventCh chan *mapv1.Event - dataDir string + store *Store + tasks *TaskRouter + worktrees *WorktreeManager + processes *ProcessManager + names *NameGenerator + githubPoller *GitHubPoller + inputMonitor *InputMonitor + eventCh chan *mapv1.Event + dataDir string grpcServer *grpc.Server listener net.Listener @@ -73,21 +75,25 @@ func NewServer(cfg *Config) (*Server, error) { processes := NewProcessManager(cfg.DataDir, eventCh) tasks := NewTaskRouter(store, processes, eventCh) names := NewNameGenerator() + githubPoller := NewGitHubPoller(store, processes, eventCh) + inputMonitor := NewInputMonitor(store, processes, eventCh) // Wire up callback to process pending tasks when agents become available processes.SetOnAgentAvailable(tasks.ProcessPendingTasks) s := &Server{ - store: store, - tasks: tasks, - worktrees: worktrees, - processes: processes, - names: names, - eventCh: eventCh, - dataDir: cfg.DataDir, - watchers: make(map[string]chan *mapv1.Event), - shutdown: make(chan struct{}), - socketPath: cfg.SocketPath, + store: store, + tasks: tasks, + worktrees: worktrees, + processes: processes, + names: names, + githubPoller: githubPoller, + inputMonitor: inputMonitor, + eventCh: eventCh, + dataDir: cfg.DataDir, + watchers: make(map[string]chan *mapv1.Event), + shutdown: make(chan struct{}), + socketPath: cfg.SocketPath, } return s, nil @@ -112,6 +118,12 @@ func (s *Server) Start() error { // Start event broadcaster go s.broadcastEvents() + // Start GitHub poller for bidirectional issue sync + s.githubPoller.Start() + + // Start input monitor to detect when agents are waiting for user input + s.inputMonitor.Start() + log.Printf("mapd listening on %s", s.socketPath) return s.grpcServer.Serve(listener) } @@ -120,6 +132,16 @@ func (s *Server) Start() error { func (s *Server) Stop() { close(s.shutdown) + // Stop GitHub poller + if s.githubPoller != nil { + s.githubPoller.Stop() + } + + // Stop input monitor + if s.inputMonitor != nil { + s.inputMonitor.Stop() + } + // Kill all spawned processes if s.processes != nil { _ = s.processes.KillAll() @@ -477,6 +499,113 @@ func (s *Server) CleanupWorktrees(ctx context.Context, req *mapv1.CleanupWorktre }, nil } +// --- Task Input Management --- + +func (s *Server) RequestInput(ctx context.Context, req *mapv1.RequestInputRequest) (*mapv1.RequestInputResponse, error) { + taskID := req.GetTaskId() + question := req.GetQuestion() + + if taskID == "" { + return nil, fmt.Errorf("task_id is required") + } + if question == "" { + return nil, fmt.Errorf("question is required") + } + + // Get the task + task, err := s.store.GetTask(taskID) + if err != nil { + return nil, fmt.Errorf("get task: %w", err) + } + if task == nil { + return &mapv1.RequestInputResponse{ + Success: false, + Message: fmt.Sprintf("task %s not found", taskID), + }, nil + } + + // Check if task has GitHub source + if task.GitHubOwner == "" || task.GitHubRepo == "" || task.GitHubIssueNumber == 0 { + return &mapv1.RequestInputResponse{ + Success: false, + Message: "task has no GitHub issue source - cannot request input", + }, nil + } + + // Post comment to GitHub + if err := PostQuestionToGitHub(task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, question); err != nil { + return &mapv1.RequestInputResponse{ + Success: false, + Message: fmt.Sprintf("failed to post to GitHub: %v", err), + }, nil + } + + // Update task status to waiting_input + if err := s.store.SetTaskWaitingInput(taskID, question); err != nil { + return &mapv1.RequestInputResponse{ + Success: false, + Message: fmt.Sprintf("failed to update task: %v", err), + }, nil + } + + // Emit event + s.emitTaskWaitingInputEvent(task, question) + + return &mapv1.RequestInputResponse{ + Success: true, + Message: fmt.Sprintf("Posted question to %s/%s#%d", task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber), + }, nil +} + +func (s *Server) GetCurrentTask(ctx context.Context, req *mapv1.GetCurrentTaskRequest) (*mapv1.GetCurrentTaskResponse, error) { + workingDir := req.GetWorkingDirectory() + if workingDir == "" { + return nil, fmt.Errorf("working_directory is required") + } + + // Find the agent by worktree path + agent, err := s.store.GetAgentByWorktreePath(workingDir) + if err != nil { + return nil, fmt.Errorf("get agent: %w", err) + } + if agent == nil { + return &mapv1.GetCurrentTaskResponse{Task: nil}, nil + } + + // Find the task assigned to this agent + task, err := s.store.GetTaskByAgentID(agent.AgentID) + if err != nil { + return nil, fmt.Errorf("get task: %w", err) + } + if task == nil { + return &mapv1.GetCurrentTaskResponse{Task: nil}, nil + } + + return &mapv1.GetCurrentTaskResponse{ + Task: s.tasks.taskRecordToProtoWithGitHub(task), + }, nil +} + +func (s *Server) emitTaskWaitingInputEvent(task *TaskRecord, question string) { + event := &mapv1.Event{ + EventId: uuid.New().String(), + Type: mapv1.EventType_EVENT_TYPE_TASK_WAITING_INPUT, + Timestamp: timestamppb.Now(), + Payload: &mapv1.Event_Task{ + Task: &mapv1.TaskEvent{ + TaskId: task.TaskID, + NewStatus: mapv1.TaskStatus_TASK_STATUS_WAITING_INPUT, + AgentId: task.AssignedTo, + }, + }, + } + + select { + case s.eventCh <- event: + default: + } +} + // Helper functions func expandPath(path string) string { @@ -503,6 +632,8 @@ func taskStatusToString(s mapv1.TaskStatus) string { return "failed" case mapv1.TaskStatus_TASK_STATUS_CANCELLED: return "cancelled" + case mapv1.TaskStatus_TASK_STATUS_WAITING_INPUT: + return "waiting_input" default: return "" } diff --git a/internal/daemon/store.go b/internal/daemon/store.go index 4a7c4a3..5f422b9 100644 --- a/internal/daemon/store.go +++ b/internal/daemon/store.go @@ -27,6 +27,13 @@ type TaskRecord struct { Error string CreatedAt time.Time UpdatedAt time.Time + // GitHub issue tracking + GitHubOwner string + GitHubRepo string + GitHubIssueNumber int + LastCommentID string + WaitingInputQuestion string + WaitingInputSince time.Time } // EventRecord represents an event in the database @@ -59,11 +66,18 @@ CREATE TABLE IF NOT EXISTS tasks ( result TEXT, error TEXT, created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + github_owner TEXT, + github_repo TEXT, + github_issue_number INTEGER, + last_comment_id TEXT, + waiting_input_question TEXT, + waiting_input_since INTEGER ); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_assigned_to ON tasks(assigned_to); +CREATE INDEX IF NOT EXISTS idx_tasks_github ON tasks(github_owner, github_repo, github_issue_number); CREATE TABLE IF NOT EXISTS events ( event_id TEXT PRIMARY KEY, @@ -113,7 +127,14 @@ func NewStore(dataDir string) (*Store, error) { return nil, fmt.Errorf("init schema: %w", err) } - return &Store{db: db}, nil + // Run migrations for existing databases + store := &Store{db: db} + if err := store.migrate(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + + return store, nil } // Close closes the database connection @@ -121,6 +142,28 @@ func (s *Store) Close() error { return s.db.Close() } +// migrate adds new columns to existing databases +func (s *Store) migrate() error { + migrations := []string{ + "ALTER TABLE tasks ADD COLUMN github_owner TEXT", + "ALTER TABLE tasks ADD COLUMN github_repo TEXT", + "ALTER TABLE tasks ADD COLUMN github_issue_number INTEGER", + "ALTER TABLE tasks ADD COLUMN last_comment_id TEXT", + "ALTER TABLE tasks ADD COLUMN waiting_input_question TEXT", + "ALTER TABLE tasks ADD COLUMN waiting_input_since INTEGER", + } + + for _, m := range migrations { + // Ignore errors - column may already exist + _, _ = s.db.Exec(m) + } + + // Ensure index exists + _, _ = s.db.Exec("CREATE INDEX IF NOT EXISTS idx_tasks_github ON tasks(github_owner, github_repo, github_issue_number)") + + return nil +} + // --- Task Operations --- // CreateTask creates a new task @@ -130,11 +173,19 @@ func (s *Store) CreateTask(task *TaskRecord) error { return fmt.Errorf("marshal scope paths: %w", err) } + var waitingInputSince int64 + if !task.WaitingInputSince.IsZero() { + waitingInputSince = task.WaitingInputSince.Unix() + } + _, err = s.db.Exec(` - INSERT INTO tasks (task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO tasks (task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at, + github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, task.TaskID, task.Description, string(paths), task.Status, task.AssignedTo, - task.Result, task.Error, task.CreatedAt.Unix(), task.UpdatedAt.Unix()) + task.Result, task.Error, task.CreatedAt.Unix(), task.UpdatedAt.Unix(), + task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, task.LastCommentID, + task.WaitingInputQuestion, waitingInputSince) return err } @@ -142,7 +193,8 @@ func (s *Store) CreateTask(task *TaskRecord) error { // GetTask retrieves a task by ID func (s *Store) GetTask(taskID string) (*TaskRecord, error) { row := s.db.QueryRow(` - SELECT task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at + SELECT task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at, + github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since FROM tasks WHERE task_id = ? `, taskID) @@ -151,8 +203,10 @@ func (s *Store) GetTask(taskID string) (*TaskRecord, error) { // ListTasks retrieves tasks with optional filters func (s *Store) ListTasks(statusFilter, agentFilter string, limit int) ([]*TaskRecord, error) { - query := `SELECT task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at FROM tasks WHERE 1=1` - args := []interface{}{} + query := `SELECT task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at, + github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since + FROM tasks WHERE 1=1` + args := []any{} if statusFilter != "" { query += " AND status = ?" @@ -195,12 +249,21 @@ func (s *Store) UpdateTask(task *TaskRecord) error { return fmt.Errorf("marshal scope paths: %w", err) } + var waitingInputSince int64 + if !task.WaitingInputSince.IsZero() { + waitingInputSince = task.WaitingInputSince.Unix() + } + _, err = s.db.Exec(` UPDATE tasks SET description = ?, scope_paths = ?, status = ?, assigned_to = ?, - result = ?, error = ?, updated_at = ? + result = ?, error = ?, updated_at = ?, + github_owner = ?, github_repo = ?, github_issue_number = ?, last_comment_id = ?, + waiting_input_question = ?, waiting_input_since = ? WHERE task_id = ? `, task.Description, string(paths), task.Status, task.AssignedTo, - task.Result, task.Error, task.UpdatedAt.Unix(), task.TaskID) + task.Result, task.Error, task.UpdatedAt.Unix(), + task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, task.LastCommentID, + task.WaitingInputQuestion, waitingInputSince, task.TaskID) return err } @@ -221,14 +284,85 @@ func (s *Store) AssignTask(taskID, instanceID string) error { return err } +// ListTasksWaitingInput returns tasks with status=waiting_input that have GitHub sources +func (s *Store) ListTasksWaitingInput() ([]*TaskRecord, error) { + rows, err := s.db.Query(` + SELECT task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at, + github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since + FROM tasks + WHERE status = 'waiting_input' AND github_owner != '' AND github_repo != '' AND github_issue_number > 0 + ORDER BY waiting_input_since ASC + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var tasks []*TaskRecord + for rows.Next() { + task, err := s.scanTaskRow(rows) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + return tasks, rows.Err() +} + +// SetTaskWaitingInput updates a task to waiting_input status with the question +func (s *Store) SetTaskWaitingInput(taskID, question string) error { + now := time.Now() + _, err := s.db.Exec(` + UPDATE tasks SET status = 'waiting_input', waiting_input_question = ?, waiting_input_since = ?, updated_at = ? + WHERE task_id = ? + `, question, now.Unix(), now.Unix(), taskID) + return err +} + +// ClearTaskWaitingInput clears the waiting input state and returns task to in_progress +func (s *Store) ClearTaskWaitingInput(taskID, lastCommentID string) error { + now := time.Now() + _, err := s.db.Exec(` + UPDATE tasks SET status = 'in_progress', waiting_input_question = '', waiting_input_since = 0, + last_comment_id = ?, updated_at = ? + WHERE task_id = ? + `, lastCommentID, now.Unix(), taskID) + return err +} + +// GetTaskByAgentID finds the in_progress or waiting_input task assigned to an agent +func (s *Store) GetTaskByAgentID(agentID string) (*TaskRecord, error) { + row := s.db.QueryRow(` + SELECT task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at, + github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since + FROM tasks + WHERE assigned_to = ? AND status IN ('in_progress', 'waiting_input') + ORDER BY updated_at DESC LIMIT 1 + `, agentID) + return s.scanTask(row) +} + +// GetAgentByWorktreePath finds the agent assigned to a worktree path +func (s *Store) GetAgentByWorktreePath(worktreePath string) (*SpawnedAgentRecord, error) { + row := s.db.QueryRow(` + SELECT agent_id, worktree_path, pid, branch, prompt, status, created_at, updated_at + FROM spawned_agents WHERE worktree_path = ? + `, worktreePath) + return s.scanSpawnedAgent(row) +} + func (s *Store) scanTask(row *sql.Row) (*TaskRecord, error) { var task TaskRecord var pathsJSON string var assignedTo, result, taskError sql.NullString - var createdAt, updatedAt int64 + var githubOwner, githubRepo, lastCommentID, waitingInputQuestion sql.NullString + var githubIssueNumber sql.NullInt64 + var createdAt, updatedAt, waitingInputSince int64 err := row.Scan(&task.TaskID, &task.Description, &pathsJSON, &task.Status, - &assignedTo, &result, &taskError, &createdAt, &updatedAt) + &assignedTo, &result, &taskError, &createdAt, &updatedAt, + &githubOwner, &githubRepo, &githubIssueNumber, &lastCommentID, + &waitingInputQuestion, &waitingInputSince) if err != nil { if err == sql.ErrNoRows { return nil, nil @@ -244,6 +378,14 @@ func (s *Store) scanTask(row *sql.Row) (*TaskRecord, error) { task.Error = taskError.String task.CreatedAt = time.Unix(createdAt, 0) task.UpdatedAt = time.Unix(updatedAt, 0) + task.GitHubOwner = githubOwner.String + task.GitHubRepo = githubRepo.String + task.GitHubIssueNumber = int(githubIssueNumber.Int64) + task.LastCommentID = lastCommentID.String + task.WaitingInputQuestion = waitingInputQuestion.String + if waitingInputSince > 0 { + task.WaitingInputSince = time.Unix(waitingInputSince, 0) + } return &task, nil } @@ -252,10 +394,14 @@ func (s *Store) scanTaskRow(rows *sql.Rows) (*TaskRecord, error) { var task TaskRecord var pathsJSON string var assignedTo, result, taskError sql.NullString - var createdAt, updatedAt int64 + var githubOwner, githubRepo, lastCommentID, waitingInputQuestion sql.NullString + var githubIssueNumber sql.NullInt64 + var createdAt, updatedAt, waitingInputSince int64 err := rows.Scan(&task.TaskID, &task.Description, &pathsJSON, &task.Status, - &assignedTo, &result, &taskError, &createdAt, &updatedAt) + &assignedTo, &result, &taskError, &createdAt, &updatedAt, + &githubOwner, &githubRepo, &githubIssueNumber, &lastCommentID, + &waitingInputQuestion, &waitingInputSince) if err != nil { return nil, err } @@ -268,6 +414,14 @@ func (s *Store) scanTaskRow(rows *sql.Rows) (*TaskRecord, error) { task.Error = taskError.String task.CreatedAt = time.Unix(createdAt, 0) task.UpdatedAt = time.Unix(updatedAt, 0) + task.GitHubOwner = githubOwner.String + task.GitHubRepo = githubRepo.String + task.GitHubIssueNumber = int(githubIssueNumber.Int64) + task.LastCommentID = lastCommentID.String + task.WaitingInputQuestion = waitingInputQuestion.String + if waitingInputSince > 0 { + task.WaitingInputSince = time.Unix(waitingInputSince, 0) + } return &task, nil } diff --git a/internal/daemon/task.go b/internal/daemon/task.go index dd7b903..42ac8af 100644 --- a/internal/daemon/task.go +++ b/internal/daemon/task.go @@ -36,14 +36,17 @@ func (r *TaskRouter) SubmitTask(ctx context.Context, req *mapv1.SubmitTaskReques taskID := uuid.New().String() now := time.Now() - // Create task record + // Create task record with optional GitHub source record := &TaskRecord{ - TaskID: taskID, - Description: req.Description, - ScopePaths: req.ScopePaths, - Status: "pending", - CreatedAt: now, - UpdatedAt: now, + TaskID: taskID, + Description: req.Description, + ScopePaths: req.ScopePaths, + Status: "pending", + CreatedAt: now, + UpdatedAt: now, + GitHubOwner: req.GetGithubOwner(), + GitHubRepo: req.GetGithubRepo(), + GitHubIssueNumber: int(req.GetGithubIssueNumber()), } if err := r.store.CreateTask(record); err != nil { @@ -59,6 +62,15 @@ func (r *TaskRouter) SubmitTask(ctx context.Context, req *mapv1.SubmitTaskReques UpdatedAt: timestamppb.New(now), } + // Add GitHub source if provided + if record.GitHubOwner != "" && record.GitHubRepo != "" && record.GitHubIssueNumber > 0 { + task.GithubSource = &mapv1.GitHubSource{ + Owner: record.GitHubOwner, + Repo: record.GitHubRepo, + IssueNumber: int32(record.GitHubIssueNumber), + } + } + // Emit task created event r.emitTaskEvent(mapv1.EventType_EVENT_TYPE_TASK_CREATED, task, "") @@ -239,6 +251,32 @@ func taskRecordToProto(rec *TaskRecord) *mapv1.Task { } } +// taskRecordToProtoWithGitHub converts TaskRecord to proto including GitHub fields +func (r *TaskRouter) taskRecordToProtoWithGitHub(rec *TaskRecord) *mapv1.Task { + task := &mapv1.Task{ + TaskId: rec.TaskID, + Description: rec.Description, + ScopePaths: rec.ScopePaths, + Status: taskStatusFromString(rec.Status), + AssignedTo: rec.AssignedTo, + Result: rec.Result, + Error: rec.Error, + CreatedAt: timestamppb.New(rec.CreatedAt), + UpdatedAt: timestamppb.New(rec.UpdatedAt), + WaitingInputQuestion: rec.WaitingInputQuestion, + } + + if rec.GitHubOwner != "" && rec.GitHubRepo != "" && rec.GitHubIssueNumber > 0 { + task.GithubSource = &mapv1.GitHubSource{ + Owner: rec.GitHubOwner, + Repo: rec.GitHubRepo, + IssueNumber: int32(rec.GitHubIssueNumber), + } + } + + return task +} + func taskStatusFromString(s string) mapv1.TaskStatus { switch s { case "pending": @@ -255,6 +293,8 @@ func taskStatusFromString(s string) mapv1.TaskStatus { return mapv1.TaskStatus_TASK_STATUS_FAILED case "cancelled": return mapv1.TaskStatus_TASK_STATUS_CANCELLED + case "waiting_input": + return mapv1.TaskStatus_TASK_STATUS_WAITING_INPUT default: return mapv1.TaskStatus_TASK_STATUS_UNSPECIFIED } diff --git a/proto/map/v1/daemon.pb.go b/proto/map/v1/daemon.pb.go index c441d90..3e918d0 100644 --- a/proto/map/v1/daemon.pb.go +++ b/proto/map/v1/daemon.pb.go @@ -29,8 +29,12 @@ type SubmitTaskRequest struct { ScopePaths []string `protobuf:"bytes,2,rep,name=scope_paths,json=scopePaths,proto3" json:"scope_paths,omitempty"` // Optional: target specific agent TargetAgentId string `protobuf:"bytes,3,opt,name=target_agent_id,json=targetAgentId,proto3" json:"target_agent_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Optional: GitHub issue source tracking + GithubOwner string `protobuf:"bytes,4,opt,name=github_owner,json=githubOwner,proto3" json:"github_owner,omitempty"` + GithubRepo string `protobuf:"bytes,5,opt,name=github_repo,json=githubRepo,proto3" json:"github_repo,omitempty"` + GithubIssueNumber int32 `protobuf:"varint,6,opt,name=github_issue_number,json=githubIssueNumber,proto3" json:"github_issue_number,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SubmitTaskRequest) Reset() { @@ -84,6 +88,27 @@ func (x *SubmitTaskRequest) GetTargetAgentId() string { return "" } +func (x *SubmitTaskRequest) GetGithubOwner() string { + if x != nil { + return x.GithubOwner + } + return "" +} + +func (x *SubmitTaskRequest) GetGithubRepo() string { + if x != nil { + return x.GithubRepo + } + return "" +} + +func (x *SubmitTaskRequest) GetGithubIssueNumber() int32 { + if x != nil { + return x.GithubIssueNumber + } + return 0 +} + // SubmitTaskResponse returns the created task type SubmitTaskResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1475,16 +1500,216 @@ func (x *CleanupWorktreesResponse) GetRemovedPaths() []string { return nil } +// RequestInputRequest signals that an agent needs user input +type RequestInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Question string `protobuf:"bytes,2,opt,name=question,proto3" json:"question,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestInputRequest) Reset() { + *x = RequestInputRequest{} + mi := &file_map_v1_daemon_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestInputRequest) ProtoMessage() {} + +func (x *RequestInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_map_v1_daemon_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RequestInputRequest.ProtoReflect.Descriptor instead. +func (*RequestInputRequest) Descriptor() ([]byte, []int) { + return file_map_v1_daemon_proto_rawDescGZIP(), []int{27} +} + +func (x *RequestInputRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *RequestInputRequest) GetQuestion() string { + if x != nil { + return x.Question + } + return "" +} + +// RequestInputResponse confirms the input request was processed +type RequestInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestInputResponse) Reset() { + *x = RequestInputResponse{} + mi := &file_map_v1_daemon_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestInputResponse) ProtoMessage() {} + +func (x *RequestInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_map_v1_daemon_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RequestInputResponse.ProtoReflect.Descriptor instead. +func (*RequestInputResponse) Descriptor() ([]byte, []int) { + return file_map_v1_daemon_proto_rawDescGZIP(), []int{28} +} + +func (x *RequestInputResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *RequestInputResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// GetCurrentTaskRequest looks up the task for a working directory +type GetCurrentTaskRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkingDirectory string `protobuf:"bytes,1,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCurrentTaskRequest) Reset() { + *x = GetCurrentTaskRequest{} + mi := &file_map_v1_daemon_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCurrentTaskRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCurrentTaskRequest) ProtoMessage() {} + +func (x *GetCurrentTaskRequest) ProtoReflect() protoreflect.Message { + mi := &file_map_v1_daemon_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCurrentTaskRequest.ProtoReflect.Descriptor instead. +func (*GetCurrentTaskRequest) Descriptor() ([]byte, []int) { + return file_map_v1_daemon_proto_rawDescGZIP(), []int{29} +} + +func (x *GetCurrentTaskRequest) GetWorkingDirectory() string { + if x != nil { + return x.WorkingDirectory + } + return "" +} + +// GetCurrentTaskResponse returns the task for the working directory +type GetCurrentTaskResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Task *Task `protobuf:"bytes,1,opt,name=task,proto3" json:"task,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetCurrentTaskResponse) Reset() { + *x = GetCurrentTaskResponse{} + mi := &file_map_v1_daemon_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetCurrentTaskResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetCurrentTaskResponse) ProtoMessage() {} + +func (x *GetCurrentTaskResponse) ProtoReflect() protoreflect.Message { + mi := &file_map_v1_daemon_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetCurrentTaskResponse.ProtoReflect.Descriptor instead. +func (*GetCurrentTaskResponse) Descriptor() ([]byte, []int) { + return file_map_v1_daemon_proto_rawDescGZIP(), []int{30} +} + +func (x *GetCurrentTaskResponse) GetTask() *Task { + if x != nil { + return x.Task + } + return nil +} + var File_map_v1_daemon_proto protoreflect.FileDescriptor const file_map_v1_daemon_proto_rawDesc = "" + "\n" + - "\x13map/v1/daemon.proto\x12\x06map.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x12map/v1/types.proto\"~\n" + + "\x13map/v1/daemon.proto\x12\x06map.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x12map/v1/types.proto\"\xf2\x01\n" + "\x11SubmitTaskRequest\x12 \n" + "\vdescription\x18\x01 \x01(\tR\vdescription\x12\x1f\n" + "\vscope_paths\x18\x02 \x03(\tR\n" + "scopePaths\x12&\n" + - "\x0ftarget_agent_id\x18\x03 \x01(\tR\rtargetAgentId\"6\n" + + "\x0ftarget_agent_id\x18\x03 \x01(\tR\rtargetAgentId\x12!\n" + + "\fgithub_owner\x18\x04 \x01(\tR\vgithubOwner\x12\x1f\n" + + "\vgithub_repo\x18\x05 \x01(\tR\n" + + "githubRepo\x12.\n" + + "\x13github_issue_number\x18\x06 \x01(\x05R\x11githubIssueNumber\"6\n" + "\x12SubmitTaskResponse\x12 \n" + "\x04task\x18\x01 \x01(\v2\f.map.v1.TaskR\x04task\"\x84\x01\n" + "\x10ListTasksRequest\x127\n" + @@ -1569,14 +1794,26 @@ const file_map_v1_daemon_proto_rawDesc = "" + "\x03all\x18\x02 \x01(\bR\x03all\"d\n" + "\x18CleanupWorktreesResponse\x12#\n" + "\rremoved_count\x18\x01 \x01(\x05R\fremovedCount\x12#\n" + - "\rremoved_paths\x18\x02 \x03(\tR\fremovedPaths2\xa5\a\n" + + "\rremoved_paths\x18\x02 \x03(\tR\fremovedPaths\"J\n" + + "\x13RequestInputRequest\x12\x17\n" + + "\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x1a\n" + + "\bquestion\x18\x02 \x01(\tR\bquestion\"J\n" + + "\x14RequestInputResponse\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"D\n" + + "\x15GetCurrentTaskRequest\x12+\n" + + "\x11working_directory\x18\x01 \x01(\tR\x10workingDirectory\":\n" + + "\x16GetCurrentTaskResponse\x12 \n" + + "\x04task\x18\x01 \x01(\v2\f.map.v1.TaskR\x04task2\xc1\b\n" + "\rDaemonService\x12C\n" + "\n" + "SubmitTask\x12\x19.map.v1.SubmitTaskRequest\x1a\x1a.map.v1.SubmitTaskResponse\x12@\n" + "\tListTasks\x12\x18.map.v1.ListTasksRequest\x1a\x19.map.v1.ListTasksResponse\x12:\n" + "\aGetTask\x12\x16.map.v1.GetTaskRequest\x1a\x17.map.v1.GetTaskResponse\x12C\n" + "\n" + - "CancelTask\x12\x19.map.v1.CancelTaskRequest\x1a\x1a.map.v1.CancelTaskResponse\x12=\n" + + "CancelTask\x12\x19.map.v1.CancelTaskRequest\x1a\x1a.map.v1.CancelTaskResponse\x12I\n" + + "\fRequestInput\x12\x1b.map.v1.RequestInputRequest\x1a\x1c.map.v1.RequestInputResponse\x12O\n" + + "\x0eGetCurrentTask\x12\x1d.map.v1.GetCurrentTaskRequest\x1a\x1e.map.v1.GetCurrentTaskResponse\x12=\n" + "\bShutdown\x12\x17.map.v1.ShutdownRequest\x1a\x18.map.v1.ShutdownResponse\x12@\n" + "\tGetStatus\x12\x18.map.v1.GetStatusRequest\x1a\x19.map.v1.GetStatusResponse\x12:\n" + "\vWatchEvents\x12\x1a.map.v1.WatchEventsRequest\x1a\r.map.v1.Event0\x01\x12C\n" + @@ -1600,7 +1837,7 @@ func file_map_v1_daemon_proto_rawDescGZIP() []byte { return file_map_v1_daemon_proto_rawDescData } -var file_map_v1_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 27) +var file_map_v1_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 31) var file_map_v1_daemon_proto_goTypes = []any{ (*SubmitTaskRequest)(nil), // 0: map.v1.SubmitTaskRequest (*SubmitTaskResponse)(nil), // 1: map.v1.SubmitTaskResponse @@ -1629,56 +1866,65 @@ var file_map_v1_daemon_proto_goTypes = []any{ (*WorktreeInfo)(nil), // 24: map.v1.WorktreeInfo (*CleanupWorktreesRequest)(nil), // 25: map.v1.CleanupWorktreesRequest (*CleanupWorktreesResponse)(nil), // 26: map.v1.CleanupWorktreesResponse - (*Task)(nil), // 27: map.v1.Task - (TaskStatus)(0), // 28: map.v1.TaskStatus - (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp - (EventType)(0), // 30: map.v1.EventType - (*Event)(nil), // 31: map.v1.Event + (*RequestInputRequest)(nil), // 27: map.v1.RequestInputRequest + (*RequestInputResponse)(nil), // 28: map.v1.RequestInputResponse + (*GetCurrentTaskRequest)(nil), // 29: map.v1.GetCurrentTaskRequest + (*GetCurrentTaskResponse)(nil), // 30: map.v1.GetCurrentTaskResponse + (*Task)(nil), // 31: map.v1.Task + (TaskStatus)(0), // 32: map.v1.TaskStatus + (*timestamppb.Timestamp)(nil), // 33: google.protobuf.Timestamp + (EventType)(0), // 34: map.v1.EventType + (*Event)(nil), // 35: map.v1.Event } var file_map_v1_daemon_proto_depIdxs = []int32{ - 27, // 0: map.v1.SubmitTaskResponse.task:type_name -> map.v1.Task - 28, // 1: map.v1.ListTasksRequest.status_filter:type_name -> map.v1.TaskStatus - 27, // 2: map.v1.ListTasksResponse.tasks:type_name -> map.v1.Task - 27, // 3: map.v1.GetTaskResponse.task:type_name -> map.v1.Task - 27, // 4: map.v1.CancelTaskResponse.task:type_name -> map.v1.Task - 29, // 5: map.v1.GetStatusResponse.started_at:type_name -> google.protobuf.Timestamp - 30, // 6: map.v1.WatchEventsRequest.type_filter:type_name -> map.v1.EventType + 31, // 0: map.v1.SubmitTaskResponse.task:type_name -> map.v1.Task + 32, // 1: map.v1.ListTasksRequest.status_filter:type_name -> map.v1.TaskStatus + 31, // 2: map.v1.ListTasksResponse.tasks:type_name -> map.v1.Task + 31, // 3: map.v1.GetTaskResponse.task:type_name -> map.v1.Task + 31, // 4: map.v1.CancelTaskResponse.task:type_name -> map.v1.Task + 33, // 5: map.v1.GetStatusResponse.started_at:type_name -> google.protobuf.Timestamp + 34, // 6: map.v1.WatchEventsRequest.type_filter:type_name -> map.v1.EventType 15, // 7: map.v1.SpawnAgentResponse.agents:type_name -> map.v1.SpawnedAgentInfo - 29, // 8: map.v1.SpawnedAgentInfo.created_at:type_name -> google.protobuf.Timestamp + 33, // 8: map.v1.SpawnedAgentInfo.created_at:type_name -> google.protobuf.Timestamp 15, // 9: map.v1.ListSpawnedAgentsResponse.agents:type_name -> map.v1.SpawnedAgentInfo 24, // 10: map.v1.ListWorktreesResponse.worktrees:type_name -> map.v1.WorktreeInfo - 29, // 11: map.v1.WorktreeInfo.created_at:type_name -> google.protobuf.Timestamp - 0, // 12: map.v1.DaemonService.SubmitTask:input_type -> map.v1.SubmitTaskRequest - 2, // 13: map.v1.DaemonService.ListTasks:input_type -> map.v1.ListTasksRequest - 4, // 14: map.v1.DaemonService.GetTask:input_type -> map.v1.GetTaskRequest - 6, // 15: map.v1.DaemonService.CancelTask:input_type -> map.v1.CancelTaskRequest - 8, // 16: map.v1.DaemonService.Shutdown:input_type -> map.v1.ShutdownRequest - 10, // 17: map.v1.DaemonService.GetStatus:input_type -> map.v1.GetStatusRequest - 12, // 18: map.v1.DaemonService.WatchEvents:input_type -> map.v1.WatchEventsRequest - 13, // 19: map.v1.DaemonService.SpawnAgent:input_type -> map.v1.SpawnAgentRequest - 16, // 20: map.v1.DaemonService.KillAgent:input_type -> map.v1.KillAgentRequest - 18, // 21: map.v1.DaemonService.ListSpawnedAgents:input_type -> map.v1.ListSpawnedAgentsRequest - 20, // 22: map.v1.DaemonService.RespawnAgent:input_type -> map.v1.RespawnAgentRequest - 22, // 23: map.v1.DaemonService.ListWorktrees:input_type -> map.v1.ListWorktreesRequest - 25, // 24: map.v1.DaemonService.CleanupWorktrees:input_type -> map.v1.CleanupWorktreesRequest - 1, // 25: map.v1.DaemonService.SubmitTask:output_type -> map.v1.SubmitTaskResponse - 3, // 26: map.v1.DaemonService.ListTasks:output_type -> map.v1.ListTasksResponse - 5, // 27: map.v1.DaemonService.GetTask:output_type -> map.v1.GetTaskResponse - 7, // 28: map.v1.DaemonService.CancelTask:output_type -> map.v1.CancelTaskResponse - 9, // 29: map.v1.DaemonService.Shutdown:output_type -> map.v1.ShutdownResponse - 11, // 30: map.v1.DaemonService.GetStatus:output_type -> map.v1.GetStatusResponse - 31, // 31: map.v1.DaemonService.WatchEvents:output_type -> map.v1.Event - 14, // 32: map.v1.DaemonService.SpawnAgent:output_type -> map.v1.SpawnAgentResponse - 17, // 33: map.v1.DaemonService.KillAgent:output_type -> map.v1.KillAgentResponse - 19, // 34: map.v1.DaemonService.ListSpawnedAgents:output_type -> map.v1.ListSpawnedAgentsResponse - 21, // 35: map.v1.DaemonService.RespawnAgent:output_type -> map.v1.RespawnAgentResponse - 23, // 36: map.v1.DaemonService.ListWorktrees:output_type -> map.v1.ListWorktreesResponse - 26, // 37: map.v1.DaemonService.CleanupWorktrees:output_type -> map.v1.CleanupWorktreesResponse - 25, // [25:38] is the sub-list for method output_type - 12, // [12:25] is the sub-list for method input_type - 12, // [12:12] is the sub-list for extension type_name - 12, // [12:12] is the sub-list for extension extendee - 0, // [0:12] is the sub-list for field type_name + 33, // 11: map.v1.WorktreeInfo.created_at:type_name -> google.protobuf.Timestamp + 31, // 12: map.v1.GetCurrentTaskResponse.task:type_name -> map.v1.Task + 0, // 13: map.v1.DaemonService.SubmitTask:input_type -> map.v1.SubmitTaskRequest + 2, // 14: map.v1.DaemonService.ListTasks:input_type -> map.v1.ListTasksRequest + 4, // 15: map.v1.DaemonService.GetTask:input_type -> map.v1.GetTaskRequest + 6, // 16: map.v1.DaemonService.CancelTask:input_type -> map.v1.CancelTaskRequest + 27, // 17: map.v1.DaemonService.RequestInput:input_type -> map.v1.RequestInputRequest + 29, // 18: map.v1.DaemonService.GetCurrentTask:input_type -> map.v1.GetCurrentTaskRequest + 8, // 19: map.v1.DaemonService.Shutdown:input_type -> map.v1.ShutdownRequest + 10, // 20: map.v1.DaemonService.GetStatus:input_type -> map.v1.GetStatusRequest + 12, // 21: map.v1.DaemonService.WatchEvents:input_type -> map.v1.WatchEventsRequest + 13, // 22: map.v1.DaemonService.SpawnAgent:input_type -> map.v1.SpawnAgentRequest + 16, // 23: map.v1.DaemonService.KillAgent:input_type -> map.v1.KillAgentRequest + 18, // 24: map.v1.DaemonService.ListSpawnedAgents:input_type -> map.v1.ListSpawnedAgentsRequest + 20, // 25: map.v1.DaemonService.RespawnAgent:input_type -> map.v1.RespawnAgentRequest + 22, // 26: map.v1.DaemonService.ListWorktrees:input_type -> map.v1.ListWorktreesRequest + 25, // 27: map.v1.DaemonService.CleanupWorktrees:input_type -> map.v1.CleanupWorktreesRequest + 1, // 28: map.v1.DaemonService.SubmitTask:output_type -> map.v1.SubmitTaskResponse + 3, // 29: map.v1.DaemonService.ListTasks:output_type -> map.v1.ListTasksResponse + 5, // 30: map.v1.DaemonService.GetTask:output_type -> map.v1.GetTaskResponse + 7, // 31: map.v1.DaemonService.CancelTask:output_type -> map.v1.CancelTaskResponse + 28, // 32: map.v1.DaemonService.RequestInput:output_type -> map.v1.RequestInputResponse + 30, // 33: map.v1.DaemonService.GetCurrentTask:output_type -> map.v1.GetCurrentTaskResponse + 9, // 34: map.v1.DaemonService.Shutdown:output_type -> map.v1.ShutdownResponse + 11, // 35: map.v1.DaemonService.GetStatus:output_type -> map.v1.GetStatusResponse + 35, // 36: map.v1.DaemonService.WatchEvents:output_type -> map.v1.Event + 14, // 37: map.v1.DaemonService.SpawnAgent:output_type -> map.v1.SpawnAgentResponse + 17, // 38: map.v1.DaemonService.KillAgent:output_type -> map.v1.KillAgentResponse + 19, // 39: map.v1.DaemonService.ListSpawnedAgents:output_type -> map.v1.ListSpawnedAgentsResponse + 21, // 40: map.v1.DaemonService.RespawnAgent:output_type -> map.v1.RespawnAgentResponse + 23, // 41: map.v1.DaemonService.ListWorktrees:output_type -> map.v1.ListWorktreesResponse + 26, // 42: map.v1.DaemonService.CleanupWorktrees:output_type -> map.v1.CleanupWorktreesResponse + 28, // [28:43] is the sub-list for method output_type + 13, // [13:28] is the sub-list for method input_type + 13, // [13:13] is the sub-list for extension type_name + 13, // [13:13] is the sub-list for extension extendee + 0, // [0:13] is the sub-list for field type_name } func init() { file_map_v1_daemon_proto_init() } @@ -1693,7 +1939,7 @@ func file_map_v1_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_map_v1_daemon_proto_rawDesc), len(file_map_v1_daemon_proto_rawDesc)), NumEnums: 0, - NumMessages: 27, + NumMessages: 31, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/map/v1/daemon.proto b/proto/map/v1/daemon.proto index ea6beda..985b701 100644 --- a/proto/map/v1/daemon.proto +++ b/proto/map/v1/daemon.proto @@ -14,6 +14,8 @@ service DaemonService { rpc ListTasks(ListTasksRequest) returns (ListTasksResponse); rpc GetTask(GetTaskRequest) returns (GetTaskResponse); rpc CancelTask(CancelTaskRequest) returns (CancelTaskResponse); + rpc RequestInput(RequestInputRequest) returns (RequestInputResponse); + rpc GetCurrentTask(GetCurrentTaskRequest) returns (GetCurrentTaskResponse); // Daemon control rpc Shutdown(ShutdownRequest) returns (ShutdownResponse); @@ -39,6 +41,10 @@ message SubmitTaskRequest { repeated string scope_paths = 2; // Optional: target specific agent string target_agent_id = 3; + // Optional: GitHub issue source tracking + string github_owner = 4; + string github_repo = 5; + int32 github_issue_number = 6; } // SubmitTaskResponse returns the created task @@ -217,3 +223,27 @@ message CleanupWorktreesResponse { int32 removed_count = 1; repeated string removed_paths = 2; } + +// --- Task Input Messages --- + +// RequestInputRequest signals that an agent needs user input +message RequestInputRequest { + string task_id = 1; + string question = 2; +} + +// RequestInputResponse confirms the input request was processed +message RequestInputResponse { + bool success = 1; + string message = 2; +} + +// GetCurrentTaskRequest looks up the task for a working directory +message GetCurrentTaskRequest { + string working_directory = 1; +} + +// GetCurrentTaskResponse returns the task for the working directory +message GetCurrentTaskResponse { + Task task = 1; +} diff --git a/proto/map/v1/daemon_grpc.pb.go b/proto/map/v1/daemon_grpc.pb.go index 70729b1..4704886 100644 --- a/proto/map/v1/daemon_grpc.pb.go +++ b/proto/map/v1/daemon_grpc.pb.go @@ -23,6 +23,8 @@ const ( DaemonService_ListTasks_FullMethodName = "/map.v1.DaemonService/ListTasks" DaemonService_GetTask_FullMethodName = "/map.v1.DaemonService/GetTask" DaemonService_CancelTask_FullMethodName = "/map.v1.DaemonService/CancelTask" + DaemonService_RequestInput_FullMethodName = "/map.v1.DaemonService/RequestInput" + DaemonService_GetCurrentTask_FullMethodName = "/map.v1.DaemonService/GetCurrentTask" DaemonService_Shutdown_FullMethodName = "/map.v1.DaemonService/Shutdown" DaemonService_GetStatus_FullMethodName = "/map.v1.DaemonService/GetStatus" DaemonService_WatchEvents_FullMethodName = "/map.v1.DaemonService/WatchEvents" @@ -45,6 +47,8 @@ type DaemonServiceClient interface { ListTasks(ctx context.Context, in *ListTasksRequest, opts ...grpc.CallOption) (*ListTasksResponse, error) GetTask(ctx context.Context, in *GetTaskRequest, opts ...grpc.CallOption) (*GetTaskResponse, error) CancelTask(ctx context.Context, in *CancelTaskRequest, opts ...grpc.CallOption) (*CancelTaskResponse, error) + RequestInput(ctx context.Context, in *RequestInputRequest, opts ...grpc.CallOption) (*RequestInputResponse, error) + GetCurrentTask(ctx context.Context, in *GetCurrentTaskRequest, opts ...grpc.CallOption) (*GetCurrentTaskResponse, error) // Daemon control Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error) GetStatus(ctx context.Context, in *GetStatusRequest, opts ...grpc.CallOption) (*GetStatusResponse, error) @@ -108,6 +112,26 @@ func (c *daemonServiceClient) CancelTask(ctx context.Context, in *CancelTaskRequ return out, nil } +func (c *daemonServiceClient) RequestInput(ctx context.Context, in *RequestInputRequest, opts ...grpc.CallOption) (*RequestInputResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RequestInputResponse) + err := c.cc.Invoke(ctx, DaemonService_RequestInput_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *daemonServiceClient) GetCurrentTask(ctx context.Context, in *GetCurrentTaskRequest, opts ...grpc.CallOption) (*GetCurrentTaskResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetCurrentTaskResponse) + err := c.cc.Invoke(ctx, DaemonService_GetCurrentTask_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *daemonServiceClient) Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ShutdownResponse) @@ -218,6 +242,8 @@ type DaemonServiceServer interface { ListTasks(context.Context, *ListTasksRequest) (*ListTasksResponse, error) GetTask(context.Context, *GetTaskRequest) (*GetTaskResponse, error) CancelTask(context.Context, *CancelTaskRequest) (*CancelTaskResponse, error) + RequestInput(context.Context, *RequestInputRequest) (*RequestInputResponse, error) + GetCurrentTask(context.Context, *GetCurrentTaskRequest) (*GetCurrentTaskResponse, error) // Daemon control Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error) GetStatus(context.Context, *GetStatusRequest) (*GetStatusResponse, error) @@ -253,6 +279,12 @@ func (UnimplementedDaemonServiceServer) GetTask(context.Context, *GetTaskRequest func (UnimplementedDaemonServiceServer) CancelTask(context.Context, *CancelTaskRequest) (*CancelTaskResponse, error) { return nil, status.Error(codes.Unimplemented, "method CancelTask not implemented") } +func (UnimplementedDaemonServiceServer) RequestInput(context.Context, *RequestInputRequest) (*RequestInputResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RequestInput not implemented") +} +func (UnimplementedDaemonServiceServer) GetCurrentTask(context.Context, *GetCurrentTaskRequest) (*GetCurrentTaskResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetCurrentTask not implemented") +} func (UnimplementedDaemonServiceServer) Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error) { return nil, status.Error(codes.Unimplemented, "method Shutdown not implemented") } @@ -373,6 +405,42 @@ func _DaemonService_CancelTask_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _DaemonService_RequestInput_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RequestInputRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).RequestInput(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_RequestInput_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).RequestInput(ctx, req.(*RequestInputRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DaemonService_GetCurrentTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetCurrentTaskRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DaemonServiceServer).GetCurrentTask(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DaemonService_GetCurrentTask_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DaemonServiceServer).GetCurrentTask(ctx, req.(*GetCurrentTaskRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _DaemonService_Shutdown_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ShutdownRequest) if err := dec(in); err != nil { @@ -551,6 +619,14 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{ MethodName: "CancelTask", Handler: _DaemonService_CancelTask_Handler, }, + { + MethodName: "RequestInput", + Handler: _DaemonService_RequestInput_Handler, + }, + { + MethodName: "GetCurrentTask", + Handler: _DaemonService_GetCurrentTask_Handler, + }, { MethodName: "Shutdown", Handler: _DaemonService_Shutdown_Handler, diff --git a/proto/map/v1/types.pb.go b/proto/map/v1/types.pb.go index 5572807..722321b 100644 --- a/proto/map/v1/types.pb.go +++ b/proto/map/v1/types.pb.go @@ -26,14 +26,15 @@ const ( type TaskStatus int32 const ( - TaskStatus_TASK_STATUS_UNSPECIFIED TaskStatus = 0 - TaskStatus_TASK_STATUS_PENDING TaskStatus = 1 - TaskStatus_TASK_STATUS_OFFERED TaskStatus = 2 - TaskStatus_TASK_STATUS_ACCEPTED TaskStatus = 3 - TaskStatus_TASK_STATUS_IN_PROGRESS TaskStatus = 4 - TaskStatus_TASK_STATUS_COMPLETED TaskStatus = 5 - TaskStatus_TASK_STATUS_FAILED TaskStatus = 6 - TaskStatus_TASK_STATUS_CANCELLED TaskStatus = 7 + TaskStatus_TASK_STATUS_UNSPECIFIED TaskStatus = 0 + TaskStatus_TASK_STATUS_PENDING TaskStatus = 1 + TaskStatus_TASK_STATUS_OFFERED TaskStatus = 2 + TaskStatus_TASK_STATUS_ACCEPTED TaskStatus = 3 + TaskStatus_TASK_STATUS_IN_PROGRESS TaskStatus = 4 + TaskStatus_TASK_STATUS_COMPLETED TaskStatus = 5 + TaskStatus_TASK_STATUS_FAILED TaskStatus = 6 + TaskStatus_TASK_STATUS_CANCELLED TaskStatus = 7 + TaskStatus_TASK_STATUS_WAITING_INPUT TaskStatus = 8 ) // Enum value maps for TaskStatus. @@ -47,16 +48,18 @@ var ( 5: "TASK_STATUS_COMPLETED", 6: "TASK_STATUS_FAILED", 7: "TASK_STATUS_CANCELLED", + 8: "TASK_STATUS_WAITING_INPUT", } TaskStatus_value = map[string]int32{ - "TASK_STATUS_UNSPECIFIED": 0, - "TASK_STATUS_PENDING": 1, - "TASK_STATUS_OFFERED": 2, - "TASK_STATUS_ACCEPTED": 3, - "TASK_STATUS_IN_PROGRESS": 4, - "TASK_STATUS_COMPLETED": 5, - "TASK_STATUS_FAILED": 6, - "TASK_STATUS_CANCELLED": 7, + "TASK_STATUS_UNSPECIFIED": 0, + "TASK_STATUS_PENDING": 1, + "TASK_STATUS_OFFERED": 2, + "TASK_STATUS_ACCEPTED": 3, + "TASK_STATUS_IN_PROGRESS": 4, + "TASK_STATUS_COMPLETED": 5, + "TASK_STATUS_FAILED": 6, + "TASK_STATUS_CANCELLED": 7, + "TASK_STATUS_WAITING_INPUT": 8, } ) @@ -91,14 +94,16 @@ func (TaskStatus) EnumDescriptor() ([]byte, []int) { type EventType int32 const ( - EventType_EVENT_TYPE_UNSPECIFIED EventType = 0 - EventType_EVENT_TYPE_TASK_CREATED EventType = 1 - EventType_EVENT_TYPE_TASK_OFFERED EventType = 2 - EventType_EVENT_TYPE_TASK_ACCEPTED EventType = 3 - EventType_EVENT_TYPE_TASK_STARTED EventType = 4 - EventType_EVENT_TYPE_TASK_COMPLETED EventType = 5 - EventType_EVENT_TYPE_TASK_FAILED EventType = 6 - EventType_EVENT_TYPE_TASK_CANCELLED EventType = 7 + EventType_EVENT_TYPE_UNSPECIFIED EventType = 0 + EventType_EVENT_TYPE_TASK_CREATED EventType = 1 + EventType_EVENT_TYPE_TASK_OFFERED EventType = 2 + EventType_EVENT_TYPE_TASK_ACCEPTED EventType = 3 + EventType_EVENT_TYPE_TASK_STARTED EventType = 4 + EventType_EVENT_TYPE_TASK_COMPLETED EventType = 5 + EventType_EVENT_TYPE_TASK_FAILED EventType = 6 + EventType_EVENT_TYPE_TASK_CANCELLED EventType = 7 + EventType_EVENT_TYPE_TASK_WAITING_INPUT EventType = 8 + EventType_EVENT_TYPE_TASK_INPUT_RECEIVED EventType = 9 ) // Enum value maps for EventType. @@ -112,16 +117,20 @@ var ( 5: "EVENT_TYPE_TASK_COMPLETED", 6: "EVENT_TYPE_TASK_FAILED", 7: "EVENT_TYPE_TASK_CANCELLED", + 8: "EVENT_TYPE_TASK_WAITING_INPUT", + 9: "EVENT_TYPE_TASK_INPUT_RECEIVED", } EventType_value = map[string]int32{ - "EVENT_TYPE_UNSPECIFIED": 0, - "EVENT_TYPE_TASK_CREATED": 1, - "EVENT_TYPE_TASK_OFFERED": 2, - "EVENT_TYPE_TASK_ACCEPTED": 3, - "EVENT_TYPE_TASK_STARTED": 4, - "EVENT_TYPE_TASK_COMPLETED": 5, - "EVENT_TYPE_TASK_FAILED": 6, - "EVENT_TYPE_TASK_CANCELLED": 7, + "EVENT_TYPE_UNSPECIFIED": 0, + "EVENT_TYPE_TASK_CREATED": 1, + "EVENT_TYPE_TASK_OFFERED": 2, + "EVENT_TYPE_TASK_ACCEPTED": 3, + "EVENT_TYPE_TASK_STARTED": 4, + "EVENT_TYPE_TASK_COMPLETED": 5, + "EVENT_TYPE_TASK_FAILED": 6, + "EVENT_TYPE_TASK_CANCELLED": 7, + "EVENT_TYPE_TASK_WAITING_INPUT": 8, + "EVENT_TYPE_TASK_INPUT_RECEIVED": 9, } ) @@ -152,25 +161,88 @@ func (EventType) EnumDescriptor() ([]byte, []int) { return file_map_v1_types_proto_rawDescGZIP(), []int{1} } -// Task represents a unit of work to be assigned to an agent -type Task struct { +// GitHubSource tracks the originating GitHub issue for a task +type GitHubSource struct { state protoimpl.MessageState `protogen:"open.v1"` - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` - ScopePaths []string `protobuf:"bytes,3,rep,name=scope_paths,json=scopePaths,proto3" json:"scope_paths,omitempty"` - Status TaskStatus `protobuf:"varint,4,opt,name=status,proto3,enum=map.v1.TaskStatus" json:"status,omitempty"` - AssignedTo string `protobuf:"bytes,5,opt,name=assigned_to,json=assignedTo,proto3" json:"assigned_to,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` - Result string `protobuf:"bytes,8,opt,name=result,proto3" json:"result,omitempty"` - Error string `protobuf:"bytes,9,opt,name=error,proto3" json:"error,omitempty"` + Owner string `protobuf:"bytes,1,opt,name=owner,proto3" json:"owner,omitempty"` + Repo string `protobuf:"bytes,2,opt,name=repo,proto3" json:"repo,omitempty"` + IssueNumber int32 `protobuf:"varint,3,opt,name=issue_number,json=issueNumber,proto3" json:"issue_number,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } +func (x *GitHubSource) Reset() { + *x = GitHubSource{} + mi := &file_map_v1_types_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GitHubSource) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GitHubSource) ProtoMessage() {} + +func (x *GitHubSource) ProtoReflect() protoreflect.Message { + mi := &file_map_v1_types_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GitHubSource.ProtoReflect.Descriptor instead. +func (*GitHubSource) Descriptor() ([]byte, []int) { + return file_map_v1_types_proto_rawDescGZIP(), []int{0} +} + +func (x *GitHubSource) GetOwner() string { + if x != nil { + return x.Owner + } + return "" +} + +func (x *GitHubSource) GetRepo() string { + if x != nil { + return x.Repo + } + return "" +} + +func (x *GitHubSource) GetIssueNumber() int32 { + if x != nil { + return x.IssueNumber + } + return 0 +} + +// Task represents a unit of work to be assigned to an agent +type Task struct { + state protoimpl.MessageState `protogen:"open.v1"` + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + ScopePaths []string `protobuf:"bytes,3,rep,name=scope_paths,json=scopePaths,proto3" json:"scope_paths,omitempty"` + Status TaskStatus `protobuf:"varint,4,opt,name=status,proto3,enum=map.v1.TaskStatus" json:"status,omitempty"` + AssignedTo string `protobuf:"bytes,5,opt,name=assigned_to,json=assignedTo,proto3" json:"assigned_to,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + Result string `protobuf:"bytes,8,opt,name=result,proto3" json:"result,omitempty"` + Error string `protobuf:"bytes,9,opt,name=error,proto3" json:"error,omitempty"` + GithubSource *GitHubSource `protobuf:"bytes,10,opt,name=github_source,json=githubSource,proto3" json:"github_source,omitempty"` + WaitingInputQuestion string `protobuf:"bytes,11,opt,name=waiting_input_question,json=waitingInputQuestion,proto3" json:"waiting_input_question,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + func (x *Task) Reset() { *x = Task{} - mi := &file_map_v1_types_proto_msgTypes[0] + mi := &file_map_v1_types_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -182,7 +254,7 @@ func (x *Task) String() string { func (*Task) ProtoMessage() {} func (x *Task) ProtoReflect() protoreflect.Message { - mi := &file_map_v1_types_proto_msgTypes[0] + mi := &file_map_v1_types_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -195,7 +267,7 @@ func (x *Task) ProtoReflect() protoreflect.Message { // Deprecated: Use Task.ProtoReflect.Descriptor instead. func (*Task) Descriptor() ([]byte, []int) { - return file_map_v1_types_proto_rawDescGZIP(), []int{0} + return file_map_v1_types_proto_rawDescGZIP(), []int{1} } func (x *Task) GetTaskId() string { @@ -261,6 +333,20 @@ func (x *Task) GetError() string { return "" } +func (x *Task) GetGithubSource() *GitHubSource { + if x != nil { + return x.GithubSource + } + return nil +} + +func (x *Task) GetWaitingInputQuestion() string { + if x != nil { + return x.WaitingInputQuestion + } + return "" +} + // TaskEvent contains task-related event data type TaskEvent struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -274,7 +360,7 @@ type TaskEvent struct { func (x *TaskEvent) Reset() { *x = TaskEvent{} - mi := &file_map_v1_types_proto_msgTypes[1] + mi := &file_map_v1_types_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -286,7 +372,7 @@ func (x *TaskEvent) String() string { func (*TaskEvent) ProtoMessage() {} func (x *TaskEvent) ProtoReflect() protoreflect.Message { - mi := &file_map_v1_types_proto_msgTypes[1] + mi := &file_map_v1_types_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -299,7 +385,7 @@ func (x *TaskEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use TaskEvent.ProtoReflect.Descriptor instead. func (*TaskEvent) Descriptor() ([]byte, []int) { - return file_map_v1_types_proto_rawDescGZIP(), []int{1} + return file_map_v1_types_proto_rawDescGZIP(), []int{2} } func (x *TaskEvent) GetTaskId() string { @@ -340,7 +426,7 @@ type StatusEvent struct { func (x *StatusEvent) Reset() { *x = StatusEvent{} - mi := &file_map_v1_types_proto_msgTypes[2] + mi := &file_map_v1_types_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -352,7 +438,7 @@ func (x *StatusEvent) String() string { func (*StatusEvent) ProtoMessage() {} func (x *StatusEvent) ProtoReflect() protoreflect.Message { - mi := &file_map_v1_types_proto_msgTypes[2] + mi := &file_map_v1_types_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -365,7 +451,7 @@ func (x *StatusEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use StatusEvent.ProtoReflect.Descriptor instead. func (*StatusEvent) Descriptor() ([]byte, []int) { - return file_map_v1_types_proto_rawDescGZIP(), []int{2} + return file_map_v1_types_proto_rawDescGZIP(), []int{3} } func (x *StatusEvent) GetMessage() string { @@ -392,7 +478,7 @@ type Event struct { func (x *Event) Reset() { *x = Event{} - mi := &file_map_v1_types_proto_msgTypes[3] + mi := &file_map_v1_types_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -404,7 +490,7 @@ func (x *Event) String() string { func (*Event) ProtoMessage() {} func (x *Event) ProtoReflect() protoreflect.Message { - mi := &file_map_v1_types_proto_msgTypes[3] + mi := &file_map_v1_types_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -417,7 +503,7 @@ func (x *Event) ProtoReflect() protoreflect.Message { // Deprecated: Use Event.ProtoReflect.Descriptor instead. func (*Event) Descriptor() ([]byte, []int) { - return file_map_v1_types_proto_rawDescGZIP(), []int{3} + return file_map_v1_types_proto_rawDescGZIP(), []int{4} } func (x *Event) GetEventId() string { @@ -486,7 +572,11 @@ var File_map_v1_types_proto protoreflect.FileDescriptor const file_map_v1_types_proto_rawDesc = "" + "\n" + - "\x12map/v1/types.proto\x12\x06map.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xd3\x02\n" + + "\x12map/v1/types.proto\x12\x06map.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"[\n" + + "\fGitHubSource\x12\x14\n" + + "\x05owner\x18\x01 \x01(\tR\x05owner\x12\x12\n" + + "\x04repo\x18\x02 \x01(\tR\x04repo\x12!\n" + + "\fissue_number\x18\x03 \x01(\x05R\vissueNumber\"\xc4\x03\n" + "\x04Task\x12\x17\n" + "\atask_id\x18\x01 \x01(\tR\x06taskId\x12 \n" + "\vdescription\x18\x02 \x01(\tR\vdescription\x12\x1f\n" + @@ -500,7 +590,10 @@ const file_map_v1_types_proto_rawDesc = "" + "\n" + "updated_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12\x16\n" + "\x06result\x18\b \x01(\tR\x06result\x12\x14\n" + - "\x05error\x18\t \x01(\tR\x05error\"\xa5\x01\n" + + "\x05error\x18\t \x01(\tR\x05error\x129\n" + + "\rgithub_source\x18\n" + + " \x01(\v2\x14.map.v1.GitHubSourceR\fgithubSource\x124\n" + + "\x16waiting_input_question\x18\v \x01(\tR\x14waitingInputQuestion\"\xa5\x01\n" + "\tTaskEvent\x12\x17\n" + "\atask_id\x18\x01 \x01(\tR\x06taskId\x121\n" + "\n" + @@ -516,7 +609,7 @@ const file_map_v1_types_proto_rawDesc = "" + "\ttimestamp\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12'\n" + "\x04task\x18\x04 \x01(\v2\x11.map.v1.TaskEventH\x00R\x04task\x12-\n" + "\x06status\x18\x05 \x01(\v2\x13.map.v1.StatusEventH\x00R\x06statusB\t\n" + - "\apayload*\xe0\x01\n" + + "\apayload*\xff\x01\n" + "\n" + "TaskStatus\x12\x1b\n" + "\x17TASK_STATUS_UNSPECIFIED\x10\x00\x12\x17\n" + @@ -526,7 +619,8 @@ const file_map_v1_types_proto_rawDesc = "" + "\x17TASK_STATUS_IN_PROGRESS\x10\x04\x12\x19\n" + "\x15TASK_STATUS_COMPLETED\x10\x05\x12\x16\n" + "\x12TASK_STATUS_FAILED\x10\x06\x12\x19\n" + - "\x15TASK_STATUS_CANCELLED\x10\a*\xf6\x01\n" + + "\x15TASK_STATUS_CANCELLED\x10\a\x12\x1d\n" + + "\x19TASK_STATUS_WAITING_INPUT\x10\b*\xbd\x02\n" + "\tEventType\x12\x1a\n" + "\x16EVENT_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n" + "\x17EVENT_TYPE_TASK_CREATED\x10\x01\x12\x1b\n" + @@ -535,7 +629,9 @@ const file_map_v1_types_proto_rawDesc = "" + "\x17EVENT_TYPE_TASK_STARTED\x10\x04\x12\x1d\n" + "\x19EVENT_TYPE_TASK_COMPLETED\x10\x05\x12\x1a\n" + "\x16EVENT_TYPE_TASK_FAILED\x10\x06\x12\x1d\n" + - "\x19EVENT_TYPE_TASK_CANCELLED\x10\aB1Z/github.com/pmarsceill/mapcli/proto/map/v1;mapv1b\x06proto3" + "\x19EVENT_TYPE_TASK_CANCELLED\x10\a\x12!\n" + + "\x1dEVENT_TYPE_TASK_WAITING_INPUT\x10\b\x12\"\n" + + "\x1eEVENT_TYPE_TASK_INPUT_RECEIVED\x10\tB1Z/github.com/pmarsceill/mapcli/proto/map/v1;mapv1b\x06proto3" var ( file_map_v1_types_proto_rawDescOnce sync.Once @@ -550,31 +646,33 @@ func file_map_v1_types_proto_rawDescGZIP() []byte { } var file_map_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_map_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_map_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_map_v1_types_proto_goTypes = []any{ (TaskStatus)(0), // 0: map.v1.TaskStatus (EventType)(0), // 1: map.v1.EventType - (*Task)(nil), // 2: map.v1.Task - (*TaskEvent)(nil), // 3: map.v1.TaskEvent - (*StatusEvent)(nil), // 4: map.v1.StatusEvent - (*Event)(nil), // 5: map.v1.Event - (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp + (*GitHubSource)(nil), // 2: map.v1.GitHubSource + (*Task)(nil), // 3: map.v1.Task + (*TaskEvent)(nil), // 4: map.v1.TaskEvent + (*StatusEvent)(nil), // 5: map.v1.StatusEvent + (*Event)(nil), // 6: map.v1.Event + (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp } var file_map_v1_types_proto_depIdxs = []int32{ - 0, // 0: map.v1.Task.status:type_name -> map.v1.TaskStatus - 6, // 1: map.v1.Task.created_at:type_name -> google.protobuf.Timestamp - 6, // 2: map.v1.Task.updated_at:type_name -> google.protobuf.Timestamp - 0, // 3: map.v1.TaskEvent.old_status:type_name -> map.v1.TaskStatus - 0, // 4: map.v1.TaskEvent.new_status:type_name -> map.v1.TaskStatus - 1, // 5: map.v1.Event.type:type_name -> map.v1.EventType - 6, // 6: map.v1.Event.timestamp:type_name -> google.protobuf.Timestamp - 3, // 7: map.v1.Event.task:type_name -> map.v1.TaskEvent - 4, // 8: map.v1.Event.status:type_name -> map.v1.StatusEvent - 9, // [9:9] is the sub-list for method output_type - 9, // [9:9] is the sub-list for method input_type - 9, // [9:9] is the sub-list for extension type_name - 9, // [9:9] is the sub-list for extension extendee - 0, // [0:9] is the sub-list for field type_name + 0, // 0: map.v1.Task.status:type_name -> map.v1.TaskStatus + 7, // 1: map.v1.Task.created_at:type_name -> google.protobuf.Timestamp + 7, // 2: map.v1.Task.updated_at:type_name -> google.protobuf.Timestamp + 2, // 3: map.v1.Task.github_source:type_name -> map.v1.GitHubSource + 0, // 4: map.v1.TaskEvent.old_status:type_name -> map.v1.TaskStatus + 0, // 5: map.v1.TaskEvent.new_status:type_name -> map.v1.TaskStatus + 1, // 6: map.v1.Event.type:type_name -> map.v1.EventType + 7, // 7: map.v1.Event.timestamp:type_name -> google.protobuf.Timestamp + 4, // 8: map.v1.Event.task:type_name -> map.v1.TaskEvent + 5, // 9: map.v1.Event.status:type_name -> map.v1.StatusEvent + 10, // [10:10] is the sub-list for method output_type + 10, // [10:10] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_map_v1_types_proto_init() } @@ -582,7 +680,7 @@ func file_map_v1_types_proto_init() { if File_map_v1_types_proto != nil { return } - file_map_v1_types_proto_msgTypes[3].OneofWrappers = []any{ + file_map_v1_types_proto_msgTypes[4].OneofWrappers = []any{ (*Event_Task)(nil), (*Event_Status)(nil), } @@ -592,7 +690,7 @@ func file_map_v1_types_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_map_v1_types_proto_rawDesc), len(file_map_v1_types_proto_rawDesc)), NumEnums: 2, - NumMessages: 4, + NumMessages: 5, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/map/v1/types.proto b/proto/map/v1/types.proto index 3521fbf..89ade18 100644 --- a/proto/map/v1/types.proto +++ b/proto/map/v1/types.proto @@ -16,6 +16,7 @@ enum TaskStatus { TASK_STATUS_COMPLETED = 5; TASK_STATUS_FAILED = 6; TASK_STATUS_CANCELLED = 7; + TASK_STATUS_WAITING_INPUT = 8; } // EventType categorizes system events @@ -28,6 +29,15 @@ enum EventType { EVENT_TYPE_TASK_COMPLETED = 5; EVENT_TYPE_TASK_FAILED = 6; EVENT_TYPE_TASK_CANCELLED = 7; + EVENT_TYPE_TASK_WAITING_INPUT = 8; + EVENT_TYPE_TASK_INPUT_RECEIVED = 9; +} + +// GitHubSource tracks the originating GitHub issue for a task +message GitHubSource { + string owner = 1; + string repo = 2; + int32 issue_number = 3; } // Task represents a unit of work to be assigned to an agent @@ -41,6 +51,8 @@ message Task { google.protobuf.Timestamp updated_at = 7; string result = 8; string error = 9; + GitHubSource github_source = 10; + string waiting_input_question = 11; } // TaskEvent contains task-related event data From b4091fe88af9781c601d3105422bcc86e2dc0a82 Mon Sep 17 00:00:00 2001 From: Patrick Marsceill Date: Fri, 23 Jan 2026 16:27:01 -0500 Subject: [PATCH 2/4] fix: update task_input.go to use getSocketPath() after config refactor After merging main, the socketPath variable was replaced with the getSocketPath() function from the new Viper configuration system. Co-Authored-By: Claude Opus 4.5 --- internal/cli/task_input.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/task_input.go b/internal/cli/task_input.go index 63d91a6..891bcc9 100644 --- a/internal/cli/task_input.go +++ b/internal/cli/task_input.go @@ -44,7 +44,7 @@ func runTaskInputNeeded(cmd *cobra.Command, args []string) error { taskID := args[0] question := strings.Join(args[1:], " ") - c, err := client.New(socketPath) + c, err := client.New(getSocketPath()) if err != nil { return fmt.Errorf("connect to daemon: %w", err) } @@ -72,7 +72,7 @@ func runTaskMyTask(cmd *cobra.Command, args []string) error { return fmt.Errorf("get working directory: %w", err) } - c, err := client.New(socketPath) + c, err := client.New(getSocketPath()) if err != nil { return fmt.Errorf("connect to daemon: %w", err) } From 709be565c21bcf97f290c212313065ca922cb94f Mon Sep 17 00:00:00 2001 From: Patrick Marsceill Date: Fri, 23 Jan 2026 16:37:42 -0500 Subject: [PATCH 3/4] fix: address code review feedback - Fix ghComment.Author to parse nested object (login field) - Extract tmuxPasteDelay constant (300ms) used in 3 places - Fix memory leak in InputMonitor by cleaning up stale agent entries Co-Authored-By: Claude Opus 4.5 --- internal/daemon/github_poller.go | 21 +++++++++++++++------ internal/daemon/input_monitor.go | 10 ++++++++++ internal/daemon/process.go | 4 ++-- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/internal/daemon/github_poller.go b/internal/daemon/github_poller.go index a84ceb8..f901258 100644 --- a/internal/daemon/github_poller.go +++ b/internal/daemon/github_poller.go @@ -26,12 +26,17 @@ type GitHubPoller struct { interval time.Duration } +// ghCommentAuthor represents the author of a GitHub comment +type ghCommentAuthor struct { + Login string `json:"login"` +} + // ghComment represents a GitHub issue comment type ghComment struct { - ID int `json:"id"` - Body string `json:"body"` - Author string `json:"author"` - CreatedAt string `json:"createdAt"` + ID int `json:"id"` + Body string `json:"body"` + Author ghCommentAuthor `json:"author"` + CreatedAt string `json:"createdAt"` } // ghIssueComments is the response from gh issue view --json comments @@ -42,6 +47,10 @@ type ghIssueComments struct { // inputRequestPrefix is the prefix we use when posting questions to GitHub const inputRequestPrefix = "**My agent needs more input:**" +// tmuxPasteDelay is the delay after sending text to tmux before sending Enter +// This allows long pastes to be processed before submission +const tmuxPasteDelay = 300 * time.Millisecond + // NewGitHubPoller creates a new GitHub poller func NewGitHubPoller(store *Store, processes *ProcessManager, eventCh chan *mapv1.Event) *GitHubPoller { return &GitHubPoller{ @@ -144,7 +153,7 @@ func (p *GitHubPoller) checkTaskForResponse(task *TaskRecord) { } log.Printf("github poller: found new comment on %s/%s#%d from %s", - task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, newComment.Author) + task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, newComment.Author.Login) // Deliver response to agent's tmux session if err := p.deliverResponseToAgent(task, newComment.Body); err != nil { @@ -211,7 +220,7 @@ func (p *GitHubPoller) deliverResponseToAgent(task *TaskRecord, response string) } // Wait for pasted text to be processed (long text shows as collapsed paste) - time.Sleep(300 * time.Millisecond) + time.Sleep(tmuxPasteDelay) // Send Enter to confirm/submit cmd = exec.Command("tmux", "send-keys", "-t", tmuxSession, "Enter") diff --git a/internal/daemon/input_monitor.go b/internal/daemon/input_monitor.go index 7e8bd36..f254577 100644 --- a/internal/daemon/input_monitor.go +++ b/internal/daemon/input_monitor.go @@ -100,10 +100,20 @@ func (m *InputMonitor) checkAllAgents() { // Get all agents agents := m.processes.List() + activeIDs := make(map[string]bool) for _, agent := range agents { + activeIDs[agent.AgentID] = true m.checkAgent(agent) } + + // Clean up stale entries for agents that no longer exist + for id := range m.lastContent { + if !activeIDs[id] { + delete(m.lastContent, id) + delete(m.lastChangeTime, id) + } + } } func (m *InputMonitor) checkAgent(agent *AgentSlot) { diff --git a/internal/daemon/process.go b/internal/daemon/process.go index fa99faa..c4c55a6 100644 --- a/internal/daemon/process.go +++ b/internal/daemon/process.go @@ -233,7 +233,7 @@ func (m *ProcessManager) ExecuteTask(ctx context.Context, agentID string, taskID // Wait for the pasted text to be processed by the terminal // Long text may show as "[Pasted text #1 +N lines]" and need confirmation - time.Sleep(300 * time.Millisecond) + time.Sleep(tmuxPasteDelay) // Send Enter key to confirm/submit the prompt // For long pastes, this confirms the paste; for short text, this submits @@ -477,7 +477,7 @@ func (m *ProcessManager) Spawn(agentID, workdir, prompt, agentType string, skipP log.Printf("warning: failed to send initial prompt text to %s: %v", agentID, err) } else { // Wait for pasted text to be processed (long text shows as collapsed paste) - time.Sleep(300 * time.Millisecond) + time.Sleep(tmuxPasteDelay) // Send Enter to confirm/submit cmd = exec.Command("tmux", "send-keys", "-t", slot.TmuxSession, "Enter") From 5d15d953112980d7b3ceb323b3d2c7bfacf44eeb Mon Sep 17 00:00:00 2001 From: Patrick Marsceill Date: Sat, 24 Jan 2026 16:46:29 -0500 Subject: [PATCH 4/4] feat: filter list commands by current repository Add repo_root filtering to all list commands (map task ls, map agent ls, map worktree ls) so they only show results for the current git repository. Changes: - Add repo_root field to proto messages for filtering and tracking - Add repo_root column to tasks and spawned_agents tables with migration - Update CLI commands to detect current repo and pass filter - Update daemon handlers to filter results by repo_root - Track repo_root when creating tasks, agents, and worktrees Co-Authored-By: Claude Opus 4.5 --- internal/cli/agent_merge.go | 5 +- internal/cli/agent_watch.go | 5 +- internal/cli/agents.go | 4 +- internal/cli/spawn.go | 35 +++++--- internal/cli/task.go | 14 ++- internal/cli/task_sync.go | 149 ++++++++++++++++++++++++++++--- internal/cli/task_sync_test.go | 13 ++- internal/cli/worktree.go | 4 +- internal/client/client.go | 20 +++-- internal/daemon/github_poller.go | 125 ++++++++++++++++++++++---- internal/daemon/process.go | 50 ++++++++--- internal/daemon/server.go | 52 +++++++++-- internal/daemon/store.go | 140 +++++++++++++++++++---------- internal/daemon/store_test.go | 143 +++++++++++++++++++++++++++-- internal/daemon/task.go | 8 +- internal/daemon/task_test.go | 8 +- internal/daemon/worktree.go | 17 ++-- proto/map/v1/daemon.pb.go | 125 ++++++++++++++++++++------ proto/map/v1/daemon.proto | 21 ++++- 19 files changed, 768 insertions(+), 170 deletions(-) diff --git a/internal/cli/agent_merge.go b/internal/cli/agent_merge.go index 0486117..54fac4a 100644 --- a/internal/cli/agent_merge.go +++ b/internal/cli/agent_merge.go @@ -55,8 +55,9 @@ func runAgentMerge(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // Find the agent - agents, err := c.ListSpawnedAgents(ctx) + // Find the agent in current repo + repoRoot := getRepoRoot() + agents, err := c.ListSpawnedAgents(ctx, repoRoot) if err != nil { return fmt.Errorf("list agents: %w", err) } diff --git a/internal/cli/agent_watch.go b/internal/cli/agent_watch.go index 9d86ae2..6a55d67 100644 --- a/internal/cli/agent_watch.go +++ b/internal/cli/agent_watch.go @@ -54,8 +54,9 @@ func runAgentWatch(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // Get list of spawned agents - agents, err := c.ListSpawnedAgents(ctx) + // Get list of spawned agents for current repo + repoRoot := getRepoRoot() + agents, err := c.ListSpawnedAgents(ctx, repoRoot) if err != nil { return fmt.Errorf("list agents: %w", err) } diff --git a/internal/cli/agents.go b/internal/cli/agents.go index 2a1ee4c..b995b4d 100644 --- a/internal/cli/agents.go +++ b/internal/cli/agents.go @@ -32,7 +32,9 @@ func runAgents(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - agents, err := c.ListSpawnedAgents(ctx) + // Filter by current repo + repoRoot := getRepoRoot() + agents, err := c.ListSpawnedAgents(ctx, repoRoot) if err != nil { return fmt.Errorf("list agents: %w", err) } diff --git a/internal/cli/spawn.go b/internal/cli/spawn.go index ce4e006..eada8ab 100644 --- a/internal/cli/spawn.go +++ b/internal/cli/spawn.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "os" "strings" "time" @@ -130,17 +131,24 @@ func runAgentCreate(cmd *cobra.Command, args []string) error { // no-worktree overrides worktree useWorktree := worktree && !noWorktree + // Get current working directory to pass to daemon + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() req := &mapv1.SpawnAgentRequest{ - Count: int32(count), - Branch: branch, - UseWorktree: useWorktree, - NamePrefix: name, - Prompt: prompt, - AgentType: agentType, - SkipPermissions: skipPermissions, + Count: int32(count), + Branch: branch, + UseWorktree: useWorktree, + NamePrefix: name, + Prompt: prompt, + AgentType: agentType, + SkipPermissions: skipPermissions, + WorkingDirectory: cwd, } resp, err := c.SpawnAgent(ctx, req) @@ -186,7 +194,9 @@ func runAgentList(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - agents, err := c.ListSpawnedAgents(ctx) + // Filter by current repo + repoRoot := getRepoRoot() + agents, err := c.ListSpawnedAgents(ctx, repoRoot) if err != nil { return fmt.Errorf("list agents: %w", err) } @@ -225,7 +235,9 @@ func runAgentKill(cmd *cobra.Command, args []string) error { // Handle --all flag if killAll { - agents, err := c.ListSpawnedAgents(ctx) + // Kill all agents in current repo + repoRoot := getRepoRoot() + agents, err := c.ListSpawnedAgents(ctx, repoRoot) if err != nil { return fmt.Errorf("list agents: %w", err) } @@ -319,9 +331,10 @@ func runAgentRespawn(cmd *cobra.Command, args []string) error { return nil } -// resolveAgentID finds an agent by exact or partial ID match +// resolveAgentID finds an agent by exact or partial ID match in the current repo func resolveAgentID(ctx context.Context, c *client.Client, agentID string) (string, error) { - agents, err := c.ListSpawnedAgents(ctx) + repoRoot := getRepoRoot() + agents, err := c.ListSpawnedAgents(ctx, repoRoot) if err != nil { return "", fmt.Errorf("list agents: %w", err) } diff --git a/internal/cli/task.go b/internal/cli/task.go index 18eab7b..0f6c8ac 100644 --- a/internal/cli/task.go +++ b/internal/cli/task.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "os/exec" "strings" "time" @@ -11,6 +12,15 @@ import ( "github.com/spf13/cobra" ) +// getRepoRoot returns the git repository root for the current directory +func getRepoRoot() string { + out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + var taskCmd = &cobra.Command{ Use: "task", Aliases: []string{"tasks", "t"}, @@ -98,7 +108,9 @@ func runTaskList(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - tasks, err := c.ListTasks(ctx, taskLimit) + // Filter by current repo + repoRoot := getRepoRoot() + tasks, err := c.ListTasks(ctx, taskLimit, repoRoot) if err != nil { return fmt.Errorf("list tasks: %w", err) } diff --git a/internal/cli/task_sync.go b/internal/cli/task_sync.go index c815a60..e2595be 100644 --- a/internal/cli/task_sync.go +++ b/internal/cli/task_sync.go @@ -17,10 +17,46 @@ type ghProject struct { ID string `json:"id"` Number int `json:"number"` Title string `json:"title"` + Owner string `json:"-"` // Owner login (user or org), populated separately } -type ghProjectList struct { - Projects []ghProject `json:"projects"` +// ghProjectRaw is used for parsing gh project list JSON output +type ghProjectRaw struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` +} + +type ghProjectListRaw struct { + Projects []ghProjectRaw `json:"projects"` +} + +// Types for GraphQL response when querying linked projects +type ghLinkedProjectsResponse struct { + Data struct { + Repository struct { + ProjectsV2 struct { + Nodes []struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` + } `json:"nodes"` + } `json:"projectsV2"` + } `json:"repository"` + } `json:"data"` +} + +type ghRepoInfo struct { + Owner struct { + Login string `json:"login"` + } `json:"owner"` + Name string `json:"name"` } type ghField struct { @@ -69,11 +105,14 @@ var taskSyncGHProjectCmd = &cobra.Command{ Long: `Fetch issues from a GitHub Project's Todo column and create tasks for them. This command: -1. Finds a GitHub Project by name +1. Finds a GitHub Project by name (searches projects linked to current repo first) 2. Fetches items from the source status column (default: "Todo") 3. Creates tasks for each item found 4. Moves items to the target status column (default: "In Progress") +By default, searches for projects linked to the current repository, which includes +projects owned by organizations. Use --owner to search a specific user/org instead. + Requires the 'gh' CLI to be installed and authenticated.`, Args: cobra.ExactArgs(1), RunE: runTaskSyncGHProject, @@ -91,7 +130,7 @@ func init() { taskSyncGHProjectCmd.Flags().StringVar(&syncStatusColumn, "status-column", "Todo", "source status column to sync from") taskSyncGHProjectCmd.Flags().StringVar(&syncTargetColumn, "target-column", "In Progress", "target status column after task creation") taskSyncGHProjectCmd.Flags().BoolVar(&syncDryRun, "dry-run", false, "preview without creating tasks or updating GitHub") - taskSyncGHProjectCmd.Flags().StringVar(&syncOwner, "owner", "@me", "GitHub project owner (user, org, or @me)") + taskSyncGHProjectCmd.Flags().StringVar(&syncOwner, "owner", "", "GitHub project owner (user or org); if empty, searches projects linked to current repo") taskSyncGHProjectCmd.Flags().IntVar(&syncLimit, "limit", 10, "maximum number of items to sync") taskSyncCmd.AddCommand(taskSyncGHProjectCmd) @@ -111,10 +150,10 @@ func runTaskSyncGHProject(cmd *cobra.Command, args []string) error { return err } - fmt.Printf("Found project: %s (#%d)\n", project.Title, project.Number) + fmt.Printf("Found project: %s (#%d) owned by %s\n", project.Title, project.Number, project.Owner) - // Get the Status field and its options - statusField, err := getStatusField(project.Number, syncOwner) + // Get the Status field and its options (use project's owner) + statusField, err := getStatusField(project.Number, project.Owner) if err != nil { return err } @@ -139,8 +178,8 @@ func runTaskSyncGHProject(cmd *cobra.Command, args []string) error { return fmt.Errorf("target column %q not found. Available options: %s", syncTargetColumn, strings.Join(availableOptions, ", ")) } - // Fetch items from the project - items, err := getProjectItems(project.Number, syncOwner) + // Fetch items from the project (use project's owner) + items, err := getProjectItems(project.Number, project.Owner) if err != nil { return err } @@ -192,7 +231,8 @@ func runTaskSyncGHProject(cmd *cobra.Command, args []string) error { // Submit task with GitHub source tracking ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - task, err := c.SubmitTaskWithGitHub(ctx, description, nil, owner, repo, int32(item.Content.Number)) + repoRoot := getRepoRoot() + task, err := c.SubmitTaskWithGitHub(ctx, description, nil, owner, repo, int32(item.Content.Number), repoRoot) cancel() if err != nil { @@ -229,6 +269,83 @@ func checkGHCLI() error { } func findProject(name, owner string) (*ghProject, error) { + // If no owner specified, try to find projects linked to the current repo first + if owner == "" { + project, err := findLinkedProject(name) + if err == nil { + return project, nil + } + // Fall back to @me if no linked projects found + owner = "@me" + } + + return findProjectByOwner(name, owner) +} + +// findLinkedProject searches for a project by name among projects linked to the current repository +func findLinkedProject(name string) (*ghProject, error) { + // Get current repo info + repoOut, err := exec.Command("gh", "repo", "view", "--json", "owner,name").Output() + if err != nil { + return nil, fmt.Errorf("not in a git repository or gh not authenticated") + } + + var repo ghRepoInfo + if err := json.Unmarshal(repoOut, &repo); err != nil { + return nil, fmt.Errorf("parse repo info: %w", err) + } + + // Query projects linked to this repository via GraphQL + query := fmt.Sprintf(`query { + repository(owner: %q, name: %q) { + projectsV2(first: 20) { + nodes { + id + number + title + owner { + ... on Organization { login } + ... on User { login } + } + } + } + } + }`, repo.Owner.Login, repo.Name) + + out, err := exec.Command("gh", "api", "graphql", "-f", "query="+query).Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("gh api graphql failed: %s", string(exitErr.Stderr)) + } + return nil, fmt.Errorf("gh api graphql failed: %w", err) + } + + var resp ghLinkedProjectsResponse + if err := json.Unmarshal(out, &resp); err != nil { + return nil, fmt.Errorf("parse linked projects: %w", err) + } + + var available []string + for _, p := range resp.Data.Repository.ProjectsV2.Nodes { + available = append(available, fmt.Sprintf("%s (owner: %s)", p.Title, p.Owner.Login)) + if strings.EqualFold(p.Title, name) { + return &ghProject{ + ID: p.ID, + Number: p.Number, + Title: p.Title, + Owner: p.Owner.Login, + }, nil + } + } + + if len(available) == 0 { + return nil, fmt.Errorf("no projects linked to repository %s/%s", repo.Owner.Login, repo.Name) + } + return nil, fmt.Errorf("project %q not found. Projects linked to this repo: %s", name, strings.Join(available, ", ")) +} + +// findProjectByOwner searches for a project by name using the gh project list command +func findProjectByOwner(name, owner string) (*ghProject, error) { args := []string{"project", "list", "--owner", owner, "--format", "json"} out, err := exec.Command("gh", args...).Output() if err != nil { @@ -238,7 +355,7 @@ func findProject(name, owner string) (*ghProject, error) { return nil, fmt.Errorf("gh project list failed: %w", err) } - var list ghProjectList + var list ghProjectListRaw if err := json.Unmarshal(out, &list); err != nil { return nil, fmt.Errorf("parse project list: %w", err) } @@ -247,7 +364,12 @@ func findProject(name, owner string) (*ghProject, error) { for _, p := range list.Projects { available = append(available, p.Title) if strings.EqualFold(p.Title, name) { - return &p, nil + return &ghProject{ + ID: p.ID, + Number: p.Number, + Title: p.Title, + Owner: p.Owner.Login, + }, nil } } @@ -309,7 +431,8 @@ func buildTaskDescription(item ghItem) string { sb.WriteString("\n\n") } - sb.WriteString(fmt.Sprintf("Source: %s", item.Content.URL)) + sb.WriteString(fmt.Sprintf("Source: %s\n\n", item.Content.URL)) + sb.WriteString("When you're done with your work and you're confident in your solution, open a PR with the GH CLI.") return sb.String() } diff --git a/internal/cli/task_sync_test.go b/internal/cli/task_sync_test.go index 236923f..43fbe51 100644 --- a/internal/cli/task_sync_test.go +++ b/internal/cli/task_sync_test.go @@ -6,14 +6,15 @@ import ( ) func TestGHProjectListParsing(t *testing.T) { + // Test parsing the actual format returned by gh project list --format json jsonData := `{ "projects": [ - {"number": 1, "title": "Project Alpha"}, - {"number": 2, "title": "Project Beta"} + {"number": 1, "title": "Project Alpha", "owner": {"login": "user1", "type": "User"}}, + {"number": 2, "title": "Project Beta", "owner": {"login": "org1", "type": "Organization"}} ] }` - var list ghProjectList + var list ghProjectListRaw if err := json.Unmarshal([]byte(jsonData), &list); err != nil { t.Fatalf("failed to parse project list: %v", err) } @@ -27,6 +28,12 @@ func TestGHProjectListParsing(t *testing.T) { if list.Projects[0].Title != "Project Alpha" { t.Errorf("expected title 'Project Alpha', got %q", list.Projects[0].Title) } + if list.Projects[0].Owner.Login != "user1" { + t.Errorf("expected owner 'user1', got %q", list.Projects[0].Owner.Login) + } + if list.Projects[1].Owner.Login != "org1" { + t.Errorf("expected owner 'org1', got %q", list.Projects[1].Owner.Login) + } } func TestGHFieldListParsing(t *testing.T) { diff --git a/internal/cli/worktree.go b/internal/cli/worktree.go index 619fadb..e289cb6 100644 --- a/internal/cli/worktree.go +++ b/internal/cli/worktree.go @@ -53,7 +53,9 @@ func runWorktreeLs(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - worktrees, err := c.ListWorktrees(ctx) + // Filter by current repo + repoRoot := getRepoRoot() + worktrees, err := c.ListWorktrees(ctx, repoRoot) if err != nil { return fmt.Errorf("list worktrees: %w", err) } diff --git a/internal/client/client.go b/internal/client/client.go index e999269..4c29e5e 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -60,13 +60,14 @@ func (c *Client) SubmitTask(ctx context.Context, description string, scopePaths } // SubmitTaskWithGitHub creates a new task with GitHub issue source tracking -func (c *Client) SubmitTaskWithGitHub(ctx context.Context, description string, scopePaths []string, owner, repo string, issueNumber int32) (*mapv1.Task, error) { +func (c *Client) SubmitTaskWithGitHub(ctx context.Context, description string, scopePaths []string, owner, repo string, issueNumber int32, repoRoot string) (*mapv1.Task, error) { resp, err := c.daemon.SubmitTask(ctx, &mapv1.SubmitTaskRequest{ Description: description, ScopePaths: scopePaths, GithubOwner: owner, GithubRepo: repo, GithubIssueNumber: issueNumber, + RepoRoot: repoRoot, }) if err != nil { return nil, err @@ -75,9 +76,10 @@ func (c *Client) SubmitTaskWithGitHub(ctx context.Context, description string, s } // ListTasks returns tasks with optional filters -func (c *Client) ListTasks(ctx context.Context, limit int32) ([]*mapv1.Task, error) { +func (c *Client) ListTasks(ctx context.Context, limit int32, repoRoot string) ([]*mapv1.Task, error) { resp, err := c.daemon.ListTasks(ctx, &mapv1.ListTasksRequest{ - Limit: limit, + Limit: limit, + RepoRoot: repoRoot, }) if err != nil { return nil, err @@ -158,8 +160,10 @@ func (c *Client) KillAgent(ctx context.Context, agentID string, force bool) (*ma } // ListSpawnedAgents returns all spawned agents -func (c *Client) ListSpawnedAgents(ctx context.Context) ([]*mapv1.SpawnedAgentInfo, error) { - resp, err := c.daemon.ListSpawnedAgents(ctx, &mapv1.ListSpawnedAgentsRequest{}) +func (c *Client) ListSpawnedAgents(ctx context.Context, repoRoot string) ([]*mapv1.SpawnedAgentInfo, error) { + resp, err := c.daemon.ListSpawnedAgents(ctx, &mapv1.ListSpawnedAgentsRequest{ + RepoRoot: repoRoot, + }) if err != nil { return nil, err } @@ -176,8 +180,10 @@ func (c *Client) RespawnAgent(ctx context.Context, agentID string) (*mapv1.Respa // --- Worktree Methods --- // ListWorktrees returns all worktrees -func (c *Client) ListWorktrees(ctx context.Context) ([]*mapv1.WorktreeInfo, error) { - resp, err := c.daemon.ListWorktrees(ctx, &mapv1.ListWorktreesRequest{}) +func (c *Client) ListWorktrees(ctx context.Context, repoRoot string) ([]*mapv1.WorktreeInfo, error) { + resp, err := c.daemon.ListWorktrees(ctx, &mapv1.ListWorktreesRequest{ + RepoRoot: repoRoot, + }) if err != nil { return nil, err } diff --git a/internal/daemon/github_poller.go b/internal/daemon/github_poller.go index f901258..04c8aae 100644 --- a/internal/daemon/github_poller.go +++ b/internal/daemon/github_poller.go @@ -33,7 +33,7 @@ type ghCommentAuthor struct { // ghComment represents a GitHub issue comment type ghComment struct { - ID int `json:"id"` + ID string `json:"id"` // GraphQL node ID (e.g., "IC_kwDOPqDJoM7iErGE") Body string `json:"body"` Author ghCommentAuthor `json:"author"` CreatedAt string `json:"createdAt"` @@ -44,12 +44,21 @@ type ghIssueComments struct { Comments []ghComment `json:"comments"` } +// ghIssueState is the response from gh issue view --json state +type ghIssueState struct { + State string `json:"state"` // "OPEN" or "CLOSED" +} + // inputRequestPrefix is the prefix we use when posting questions to GitHub const inputRequestPrefix = "**My agent needs more input:**" // tmuxPasteDelay is the delay after sending text to tmux before sending Enter // This allows long pastes to be processed before submission -const tmuxPasteDelay = 300 * time.Millisecond +const tmuxPasteDelay = 1 * time.Second + +// tmuxEnterDelay is the delay between Enter key presses +// Long pastes show as "[Pasted text #1 +N lines]" and need Enter to expand, then another to submit +const tmuxEnterDelay = 500 * time.Millisecond // NewGitHubPoller creates a new GitHub poller func NewGitHubPoller(store *Store, processes *ProcessManager, eventCh chan *mapv1.Event) *GitHubPoller { @@ -93,15 +102,24 @@ func (p *GitHubPoller) poll() { p.mu.Lock() defer p.mu.Unlock() - // Get all tasks waiting for input - tasks, err := p.store.ListTasksWaitingInput() + // Get all tasks waiting for input and check for responses + waitingTasks, err := p.store.ListTasksWaitingInput() if err != nil { log.Printf("github poller: failed to list waiting tasks: %v", err) - return + } else { + for _, task := range waitingTasks { + p.checkTaskForResponse(task) + } } - for _, task := range tasks { - p.checkTaskForResponse(task) + // Get all in_progress tasks with GitHub sources and check if issues are closed + inProgressTasks, err := p.store.ListTasksInProgressWithGitHub() + if err != nil { + log.Printf("github poller: failed to list in_progress tasks: %v", err) + } else { + for _, task := range inProgressTasks { + p.checkTaskForClosedIssue(task) + } } } @@ -136,11 +154,8 @@ func (p *GitHubPoller) checkTaskForResponse(task *TaskRecord) { } // Skip if we've already processed this comment - if task.LastCommentID != "" { - lastID, _ := strconv.Atoi(task.LastCommentID) - if c.ID <= lastID { - continue - } + if task.LastCommentID != "" && c.ID == task.LastCommentID { + continue } // Found a new human comment @@ -162,7 +177,7 @@ func (p *GitHubPoller) checkTaskForResponse(task *TaskRecord) { } // Update task status back to in_progress - if err := p.store.ClearTaskWaitingInput(task.TaskID, strconv.Itoa(newComment.ID)); err != nil { + if err := p.store.ClearTaskWaitingInput(task.TaskID, newComment.ID); err != nil { log.Printf("github poller: failed to update task status: %v", err) return } @@ -196,6 +211,76 @@ func (p *GitHubPoller) fetchGitHubComments(owner, repo string, issueNumber int) return result.Comments, nil } +func (p *GitHubPoller) checkTaskForClosedIssue(task *TaskRecord) { + state, err := p.fetchGitHubIssueState(task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber) + if err != nil { + log.Printf("github poller: failed to fetch issue state for %s/%s#%d: %v", + task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, err) + return + } + + if state == "CLOSED" { + log.Printf("github poller: issue %s/%s#%d is closed, marking task %s as completed", + task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, task.TaskID) + + // Mark the task as completed + if err := p.store.UpdateTaskStatus(task.TaskID, "completed"); err != nil { + log.Printf("github poller: failed to mark task %s as completed: %v", task.TaskID, err) + return + } + + // Emit completion event + p.emitTaskCompletedEvent(task) + } +} + +func (p *GitHubPoller) fetchGitHubIssueState(owner, repo string, issueNumber int) (string, error) { + args := []string{ + "issue", "view", strconv.Itoa(issueNumber), + "--repo", fmt.Sprintf("%s/%s", owner, repo), + "--json", "state", + } + + out, err := exec.Command("gh", args...).Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("gh issue view failed: %s", string(exitErr.Stderr)) + } + return "", fmt.Errorf("gh issue view failed: %w", err) + } + + var result ghIssueState + if err := json.Unmarshal(out, &result); err != nil { + return "", fmt.Errorf("parse issue state: %w", err) + } + + return result.State, nil +} + +func (p *GitHubPoller) emitTaskCompletedEvent(task *TaskRecord) { + if p.eventCh == nil { + return + } + + event := &mapv1.Event{ + EventId: uuid.New().String(), + Type: mapv1.EventType_EVENT_TYPE_TASK_COMPLETED, + Timestamp: timestamppb.Now(), + Payload: &mapv1.Event_Task{ + Task: &mapv1.TaskEvent{ + TaskId: task.TaskID, + NewStatus: mapv1.TaskStatus_TASK_STATUS_COMPLETED, + AgentId: task.AssignedTo, + }, + }, + } + + select { + case p.eventCh <- event: + default: + } +} + func (p *GitHubPoller) deliverResponseToAgent(task *TaskRecord, response string) error { if task.AssignedTo == "" { return fmt.Errorf("task has no assigned agent") @@ -222,10 +307,20 @@ func (p *GitHubPoller) deliverResponseToAgent(task *TaskRecord, response string) // Wait for pasted text to be processed (long text shows as collapsed paste) time.Sleep(tmuxPasteDelay) - // Send Enter to confirm/submit + // Send Enter twice for long pastes: + // 1st Enter: confirms/expands the collapsed paste preview + // 2nd Enter: submits the prompt to the CLI + cmd = exec.Command("tmux", "send-keys", "-t", tmuxSession, "Enter") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to send first Enter: %w", err) + } + + // Wait for paste to expand before sending second Enter + time.Sleep(tmuxEnterDelay) + cmd = exec.Command("tmux", "send-keys", "-t", tmuxSession, "Enter") if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to send Enter: %w", err) + return fmt.Errorf("failed to send second Enter: %w", err) } return nil diff --git a/internal/daemon/process.go b/internal/daemon/process.go index c4c55a6..01649c2 100644 --- a/internal/daemon/process.go +++ b/internal/daemon/process.go @@ -34,6 +34,7 @@ type AgentSlot struct { Status string // "idle", "busy" CurrentTask string // current task ID if busy AgentType string // "claude" or "codex" + RepoRoot string // git repository root the agent was spawned from mu sync.Mutex } @@ -73,7 +74,8 @@ func (m *ProcessManager) SetOnAgentAvailable(callback func()) { // CreateSlot creates a new agent with a tmux session running claude or codex // agentType should be "claude" (default) or "codex" // If skipPermissions is true, the agent is started with permission-bypassing flags -func (m *ProcessManager) CreateSlot(agentID, workdir, agentType string, skipPermissions bool) (*AgentSlot, error) { +// repoRoot is the git repository root the agent was spawned from +func (m *ProcessManager) CreateSlot(agentID, workdir, agentType, repoRoot string, skipPermissions bool) (*AgentSlot, error) { m.mu.Lock() defer m.mu.Unlock() @@ -153,6 +155,7 @@ func (m *ProcessManager) CreateSlot(agentID, workdir, agentType string, skipPerm CreatedAt: time.Now(), Status: AgentStatusIdle, AgentType: agentType, + RepoRoot: repoRoot, } m.agents[agentID] = slot @@ -235,11 +238,23 @@ func (m *ProcessManager) ExecuteTask(ctx context.Context, agentID string, taskID // Long text may show as "[Pasted text #1 +N lines]" and need confirmation time.Sleep(tmuxPasteDelay) - // Send Enter key to confirm/submit the prompt - // For long pastes, this confirms the paste; for short text, this submits + // Send Enter key twice for long pastes: + // 1st Enter: confirms/expands the collapsed paste preview + // 2nd Enter: submits the prompt to the CLI + // For short pastes, the first Enter submits and the second is harmless cmd = exec.CommandContext(ctx, "tmux", "send-keys", "-t", tmuxSession, "Enter") if err := cmd.Run(); err != nil { - log.Printf("agent %s task %s failed to send Enter: %v", agentID, taskID, err) + log.Printf("agent %s task %s failed to send first Enter: %v", agentID, taskID, err) + return "", fmt.Errorf("failed to confirm paste in tmux: %w", err) + } + + // Wait for paste to expand before sending second Enter + time.Sleep(tmuxEnterDelay) + + // Second Enter to submit the prompt + cmd = exec.CommandContext(ctx, "tmux", "send-keys", "-t", tmuxSession, "Enter") + if err := cmd.Run(); err != nil { + log.Printf("agent %s task %s failed to send second Enter: %v", agentID, taskID, err) return "", fmt.Errorf("failed to submit task to tmux: %w", err) } @@ -423,6 +438,7 @@ func (slot *AgentSlot) ToProto() *mapv1.SpawnedAgentInfo { CreatedAt: timestamppb.New(slot.CreatedAt), LogFile: slot.TmuxSession, // Repurpose LogFile to show tmux session AgentType: slot.AgentType, + RepoRoot: slot.RepoRoot, } } @@ -456,16 +472,18 @@ func (m *ProcessManager) emitAgentEvent(slot *AgentSlot, connected bool) { // Spawn creates a slot and optionally sends an initial prompt // agentType should be "claude" (default) or "codex" // If skipPermissions is true, the agent is started with permission-bypassing flags -func (m *ProcessManager) Spawn(agentID, workdir, prompt, agentType string, skipPermissions bool) (*AgentSlot, error) { - slot, err := m.CreateSlot(agentID, workdir, agentType, skipPermissions) +// repoRoot is the git repository root the agent was spawned from +func (m *ProcessManager) Spawn(agentID, workdir, prompt, agentType, repoRoot string, skipPermissions bool) (*AgentSlot, error) { + slot, err := m.CreateSlot(agentID, workdir, agentType, repoRoot, skipPermissions) if err != nil { return nil, err } // If a prompt was provided, send it to the tmux session if prompt != "" { - // Give the agent a moment to start up - time.Sleep(500 * time.Millisecond) + // Give the agent time to fully start up and be ready for input + // Claude Code needs ~2s to initialize its UI + time.Sleep(2 * time.Second) // Replace newlines with spaces to keep as single-line input singleLinePrompt := strings.ReplaceAll(prompt, "\n", " ") @@ -479,12 +497,22 @@ func (m *ProcessManager) Spawn(agentID, workdir, prompt, agentType string, skipP // Wait for pasted text to be processed (long text shows as collapsed paste) time.Sleep(tmuxPasteDelay) - // Send Enter to confirm/submit + // Send Enter twice for long pastes: + // 1st Enter: confirms/expands the collapsed paste preview + // 2nd Enter: submits the prompt to the CLI cmd = exec.Command("tmux", "send-keys", "-t", slot.TmuxSession, "Enter") if err := cmd.Run(); err != nil { - log.Printf("warning: failed to send Enter to %s: %v", agentID, err) + log.Printf("warning: failed to send first Enter to %s: %v", agentID, err) } else { - log.Printf("sent initial prompt to agent %s", agentID) + // Wait for paste to expand before sending second Enter + time.Sleep(tmuxEnterDelay) + + cmd = exec.Command("tmux", "send-keys", "-t", slot.TmuxSession, "Enter") + if err := cmd.Run(); err != nil { + log.Printf("warning: failed to send second Enter to %s: %v", agentID, err) + } else { + log.Printf("sent initial prompt to agent %s", agentID) + } } } } diff --git a/internal/daemon/server.go b/internal/daemon/server.go index cd18842..d23a674 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -6,7 +6,9 @@ import ( "log" "net" "os" + "os/exec" "path/filepath" + "strings" "sync" "time" @@ -197,7 +199,7 @@ func (s *Server) ListTasks(ctx context.Context, req *mapv1.ListTasksRequest) (*m statusFilter = taskStatusToString(req.StatusFilter) } - tasks, err := s.tasks.ListTasks(statusFilter, req.AgentFilter, int(req.Limit)) + tasks, err := s.tasks.ListTasks(statusFilter, req.AgentFilter, req.GetRepoRoot(), int(req.Limit)) if err != nil { return nil, err } @@ -305,6 +307,24 @@ func (s *Server) SpawnAgent(ctx context.Context, req *mapv1.SpawnAgentRequest) ( var agents []*mapv1.SpawnedAgentInfo + // Determine the repo root to use for worktrees + // Use the client's working directory if provided, otherwise fall back to daemon's + clientWorkDir := req.GetWorkingDirectory() + var repoRoot string + if clientWorkDir != "" { + // Find git repo root from the client's working directory + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + cmd.Dir = clientWorkDir + out, err := cmd.Output() + if err == nil { + repoRoot = strings.TrimSpace(string(out)) + } + } + if repoRoot == "" { + // Fall back to daemon's repo root + repoRoot = s.worktrees.GetRepoRoot() + } + for i := 0; i < count; i++ { var agentID string if namePrefix != "" { @@ -319,17 +339,20 @@ func (s *Server) SpawnAgent(ctx context.Context, req *mapv1.SpawnAgentRequest) ( var worktreePath string if req.GetUseWorktree() { - // Create worktree for isolation - wt, err := s.worktrees.Create(agentID, req.GetBranch()) + // Create worktree for isolation using the determined repo root + wt, err := s.worktrees.CreateFromRepo(agentID, req.GetBranch(), repoRoot) if err != nil { return nil, fmt.Errorf("create worktree for %s: %w", agentID, err) } workdir = wt.Path worktreePath = wt.Path } else { - // Use the repo root or current directory - workdir = s.worktrees.GetRepoRoot() - if workdir == "" { + // Use the client's working directory, repo root, or daemon's cwd + if clientWorkDir != "" { + workdir = clientWorkDir + } else if repoRoot != "" { + workdir = repoRoot + } else { var err error workdir, err = os.Getwd() if err != nil { @@ -347,7 +370,7 @@ func (s *Server) SpawnAgent(ctx context.Context, req *mapv1.SpawnAgentRequest) ( // Neither flag set - default to skipping permissions for autonomous operation skipPermissions = true } - slot, err := s.processes.Spawn(agentID, workdir, req.GetPrompt(), agentType, skipPermissions) + slot, err := s.processes.Spawn(agentID, workdir, req.GetPrompt(), agentType, repoRoot, skipPermissions) if err != nil { // Cleanup worktree if we created one if worktreePath != "" { @@ -367,6 +390,7 @@ func (s *Server) SpawnAgent(ctx context.Context, req *mapv1.SpawnAgentRequest) ( Status: AgentStatusIdle, CreatedAt: now, UpdatedAt: now, + RepoRoot: repoRoot, } if err := s.store.CreateSpawnedAgent(record); err != nil { log.Printf("failed to store spawned agent %s: %v", agentID, err) @@ -418,10 +442,16 @@ func (s *Server) KillAgent(ctx context.Context, req *mapv1.KillAgentRequest) (*m func (s *Server) ListSpawnedAgents(ctx context.Context, req *mapv1.ListSpawnedAgentsRequest) (*mapv1.ListSpawnedAgentsResponse, error) { processes := s.processes.List() + repoFilter := req.GetRepoRoot() agents := make([]*mapv1.SpawnedAgentInfo, 0, len(processes)) for _, sp := range processes { - agents = append(agents, sp.ToProto()) + info := sp.ToProto() + // Filter by repo if specified + if repoFilter != "" && info.RepoRoot != repoFilter { + continue + } + agents = append(agents, info) } return &mapv1.ListSpawnedAgentsResponse{Agents: agents}, nil @@ -460,14 +490,20 @@ func (s *Server) RespawnAgent(ctx context.Context, req *mapv1.RespawnAgentReques func (s *Server) ListWorktrees(ctx context.Context, req *mapv1.ListWorktreesRequest) (*mapv1.ListWorktreesResponse, error) { worktrees := s.worktrees.List() + repoFilter := req.GetRepoRoot() infos := make([]*mapv1.WorktreeInfo, 0, len(worktrees)) for _, wt := range worktrees { + // Filter by repo if specified + if repoFilter != "" && wt.RepoRoot != repoFilter { + continue + } infos = append(infos, &mapv1.WorktreeInfo{ AgentId: wt.AgentID, Path: wt.Path, Branch: wt.Branch, CreatedAt: timestamppb.New(wt.CreatedAt), + RepoRoot: wt.RepoRoot, }) } diff --git a/internal/daemon/store.go b/internal/daemon/store.go index 5f422b9..1b9617d 100644 --- a/internal/daemon/store.go +++ b/internal/daemon/store.go @@ -34,6 +34,8 @@ type TaskRecord struct { LastCommentID string WaitingInputQuestion string WaitingInputSince time.Time + // Repository root this task belongs to + RepoRoot string } // EventRecord represents an event in the database @@ -54,6 +56,8 @@ type SpawnedAgentRecord struct { Status string CreatedAt time.Time UpdatedAt time.Time + // Repository root the agent was spawned from + RepoRoot string } const schema = ` @@ -72,12 +76,13 @@ CREATE TABLE IF NOT EXISTS tasks ( github_issue_number INTEGER, last_comment_id TEXT, waiting_input_question TEXT, - waiting_input_since INTEGER + waiting_input_since INTEGER, + repo_root TEXT ); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_assigned_to ON tasks(assigned_to); -CREATE INDEX IF NOT EXISTS idx_tasks_github ON tasks(github_owner, github_repo, github_issue_number); +-- Note: idx_tasks_github is created in migrate() to support existing databases CREATE TABLE IF NOT EXISTS events ( event_id TEXT PRIMARY KEY, @@ -97,7 +102,8 @@ CREATE TABLE IF NOT EXISTS spawned_agents ( prompt TEXT, status TEXT DEFAULT 'running', created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + repo_root TEXT ); CREATE INDEX IF NOT EXISTS idx_spawned_agents_status ON spawned_agents(status); @@ -151,6 +157,8 @@ func (s *Store) migrate() error { "ALTER TABLE tasks ADD COLUMN last_comment_id TEXT", "ALTER TABLE tasks ADD COLUMN waiting_input_question TEXT", "ALTER TABLE tasks ADD COLUMN waiting_input_since INTEGER", + "ALTER TABLE tasks ADD COLUMN repo_root TEXT", + "ALTER TABLE spawned_agents ADD COLUMN repo_root TEXT", } for _, m := range migrations { @@ -158,8 +166,10 @@ func (s *Store) migrate() error { _, _ = s.db.Exec(m) } - // Ensure index exists + // Ensure indexes exist _, _ = s.db.Exec("CREATE INDEX IF NOT EXISTS idx_tasks_github ON tasks(github_owner, github_repo, github_issue_number)") + _, _ = s.db.Exec("CREATE INDEX IF NOT EXISTS idx_tasks_repo_root ON tasks(repo_root)") + _, _ = s.db.Exec("CREATE INDEX IF NOT EXISTS idx_spawned_agents_repo_root ON spawned_agents(repo_root)") return nil } @@ -180,12 +190,12 @@ func (s *Store) CreateTask(task *TaskRecord) error { _, err = s.db.Exec(` INSERT INTO tasks (task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at, - github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since, repo_root) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, task.TaskID, task.Description, string(paths), task.Status, task.AssignedTo, task.Result, task.Error, task.CreatedAt.Unix(), task.UpdatedAt.Unix(), task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, task.LastCommentID, - task.WaitingInputQuestion, waitingInputSince) + task.WaitingInputQuestion, waitingInputSince, task.RepoRoot) return err } @@ -194,7 +204,7 @@ func (s *Store) CreateTask(task *TaskRecord) error { func (s *Store) GetTask(taskID string) (*TaskRecord, error) { row := s.db.QueryRow(` SELECT task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at, - github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since + github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since, repo_root FROM tasks WHERE task_id = ? `, taskID) @@ -202,9 +212,9 @@ func (s *Store) GetTask(taskID string) (*TaskRecord, error) { } // ListTasks retrieves tasks with optional filters -func (s *Store) ListTasks(statusFilter, agentFilter string, limit int) ([]*TaskRecord, error) { +func (s *Store) ListTasks(statusFilter, agentFilter, repoRoot string, limit int) ([]*TaskRecord, error) { query := `SELECT task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at, - github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since + github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since, repo_root FROM tasks WHERE 1=1` args := []any{} @@ -216,6 +226,10 @@ func (s *Store) ListTasks(statusFilter, agentFilter string, limit int) ([]*TaskR query += " AND assigned_to = ?" args = append(args, agentFilter) } + if repoRoot != "" { + query += " AND repo_root = ?" + args = append(args, repoRoot) + } query += " ORDER BY created_at DESC" @@ -258,12 +272,12 @@ func (s *Store) UpdateTask(task *TaskRecord) error { UPDATE tasks SET description = ?, scope_paths = ?, status = ?, assigned_to = ?, result = ?, error = ?, updated_at = ?, github_owner = ?, github_repo = ?, github_issue_number = ?, last_comment_id = ?, - waiting_input_question = ?, waiting_input_since = ? + waiting_input_question = ?, waiting_input_since = ?, repo_root = ? WHERE task_id = ? `, task.Description, string(paths), task.Status, task.AssignedTo, task.Result, task.Error, task.UpdatedAt.Unix(), task.GitHubOwner, task.GitHubRepo, task.GitHubIssueNumber, task.LastCommentID, - task.WaitingInputQuestion, waitingInputSince, task.TaskID) + task.WaitingInputQuestion, waitingInputSince, task.RepoRoot, task.TaskID) return err } @@ -288,7 +302,7 @@ func (s *Store) AssignTask(taskID, instanceID string) error { func (s *Store) ListTasksWaitingInput() ([]*TaskRecord, error) { rows, err := s.db.Query(` SELECT task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at, - github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since + github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since, repo_root FROM tasks WHERE status = 'waiting_input' AND github_owner != '' AND github_repo != '' AND github_issue_number > 0 ORDER BY waiting_input_since ASC @@ -309,6 +323,31 @@ func (s *Store) ListTasksWaitingInput() ([]*TaskRecord, error) { return tasks, rows.Err() } +// ListTasksInProgressWithGitHub returns tasks with status=in_progress that have GitHub sources +func (s *Store) ListTasksInProgressWithGitHub() ([]*TaskRecord, error) { + rows, err := s.db.Query(` + SELECT task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at, + github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since, repo_root + FROM tasks + WHERE status = 'in_progress' AND github_owner != '' AND github_repo != '' AND github_issue_number > 0 + ORDER BY updated_at DESC + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var tasks []*TaskRecord + for rows.Next() { + task, err := s.scanTaskRow(rows) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + return tasks, rows.Err() +} + // SetTaskWaitingInput updates a task to waiting_input status with the question func (s *Store) SetTaskWaitingInput(taskID, question string) error { now := time.Now() @@ -334,7 +373,7 @@ func (s *Store) ClearTaskWaitingInput(taskID, lastCommentID string) error { func (s *Store) GetTaskByAgentID(agentID string) (*TaskRecord, error) { row := s.db.QueryRow(` SELECT task_id, description, scope_paths, status, assigned_to, result, error, created_at, updated_at, - github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since + github_owner, github_repo, github_issue_number, last_comment_id, waiting_input_question, waiting_input_since, repo_root FROM tasks WHERE assigned_to = ? AND status IN ('in_progress', 'waiting_input') ORDER BY updated_at DESC LIMIT 1 @@ -345,7 +384,7 @@ func (s *Store) GetTaskByAgentID(agentID string) (*TaskRecord, error) { // GetAgentByWorktreePath finds the agent assigned to a worktree path func (s *Store) GetAgentByWorktreePath(worktreePath string) (*SpawnedAgentRecord, error) { row := s.db.QueryRow(` - SELECT agent_id, worktree_path, pid, branch, prompt, status, created_at, updated_at + SELECT agent_id, worktree_path, pid, branch, prompt, status, created_at, updated_at, repo_root FROM spawned_agents WHERE worktree_path = ? `, worktreePath) return s.scanSpawnedAgent(row) @@ -355,14 +394,14 @@ func (s *Store) scanTask(row *sql.Row) (*TaskRecord, error) { var task TaskRecord var pathsJSON string var assignedTo, result, taskError sql.NullString - var githubOwner, githubRepo, lastCommentID, waitingInputQuestion sql.NullString - var githubIssueNumber sql.NullInt64 - var createdAt, updatedAt, waitingInputSince int64 + var githubOwner, githubRepo, lastCommentID, waitingInputQuestion, repoRoot sql.NullString + var githubIssueNumber, waitingInputSince sql.NullInt64 + var createdAt, updatedAt int64 err := row.Scan(&task.TaskID, &task.Description, &pathsJSON, &task.Status, &assignedTo, &result, &taskError, &createdAt, &updatedAt, &githubOwner, &githubRepo, &githubIssueNumber, &lastCommentID, - &waitingInputQuestion, &waitingInputSince) + &waitingInputQuestion, &waitingInputSince, &repoRoot) if err != nil { if err == sql.ErrNoRows { return nil, nil @@ -383,9 +422,10 @@ func (s *Store) scanTask(row *sql.Row) (*TaskRecord, error) { task.GitHubIssueNumber = int(githubIssueNumber.Int64) task.LastCommentID = lastCommentID.String task.WaitingInputQuestion = waitingInputQuestion.String - if waitingInputSince > 0 { - task.WaitingInputSince = time.Unix(waitingInputSince, 0) + if waitingInputSince.Valid && waitingInputSince.Int64 > 0 { + task.WaitingInputSince = time.Unix(waitingInputSince.Int64, 0) } + task.RepoRoot = repoRoot.String return &task, nil } @@ -394,14 +434,14 @@ func (s *Store) scanTaskRow(rows *sql.Rows) (*TaskRecord, error) { var task TaskRecord var pathsJSON string var assignedTo, result, taskError sql.NullString - var githubOwner, githubRepo, lastCommentID, waitingInputQuestion sql.NullString - var githubIssueNumber sql.NullInt64 - var createdAt, updatedAt, waitingInputSince int64 + var githubOwner, githubRepo, lastCommentID, waitingInputQuestion, repoRoot sql.NullString + var githubIssueNumber, waitingInputSince sql.NullInt64 + var createdAt, updatedAt int64 err := rows.Scan(&task.TaskID, &task.Description, &pathsJSON, &task.Status, &assignedTo, &result, &taskError, &createdAt, &updatedAt, &githubOwner, &githubRepo, &githubIssueNumber, &lastCommentID, - &waitingInputQuestion, &waitingInputSince) + &waitingInputQuestion, &waitingInputSince, &repoRoot) if err != nil { return nil, err } @@ -419,9 +459,10 @@ func (s *Store) scanTaskRow(rows *sql.Rows) (*TaskRecord, error) { task.GitHubIssueNumber = int(githubIssueNumber.Int64) task.LastCommentID = lastCommentID.String task.WaitingInputQuestion = waitingInputQuestion.String - if waitingInputSince > 0 { - task.WaitingInputSince = time.Unix(waitingInputSince, 0) + if waitingInputSince.Valid && waitingInputSince.Int64 > 0 { + task.WaitingInputSince = time.Unix(waitingInputSince.Int64, 0) } + task.RepoRoot = repoRoot.String return &task, nil } @@ -481,40 +522,41 @@ func (s *Store) GetStats() (pendingTasks, activeTasks int, err error) { // CreateSpawnedAgent creates a new spawned agent record func (s *Store) CreateSpawnedAgent(agent *SpawnedAgentRecord) error { _, err := s.db.Exec(` - INSERT INTO spawned_agents (agent_id, worktree_path, pid, branch, prompt, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO spawned_agents (agent_id, worktree_path, pid, branch, prompt, status, created_at, updated_at, repo_root) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, agent.AgentID, agent.WorktreePath, agent.PID, agent.Branch, agent.Prompt, agent.Status, - agent.CreatedAt.Unix(), agent.UpdatedAt.Unix()) + agent.CreatedAt.Unix(), agent.UpdatedAt.Unix(), agent.RepoRoot) return err } // GetSpawnedAgent retrieves a spawned agent by ID func (s *Store) GetSpawnedAgent(agentID string) (*SpawnedAgentRecord, error) { row := s.db.QueryRow(` - SELECT agent_id, worktree_path, pid, branch, prompt, status, created_at, updated_at + SELECT agent_id, worktree_path, pid, branch, prompt, status, created_at, updated_at, repo_root FROM spawned_agents WHERE agent_id = ? `, agentID) return s.scanSpawnedAgent(row) } -// ListSpawnedAgents retrieves all spawned agents, optionally filtered by status -func (s *Store) ListSpawnedAgents(statusFilter string) ([]*SpawnedAgentRecord, error) { - var rows *sql.Rows - var err error +// ListSpawnedAgents retrieves all spawned agents, optionally filtered by status and repo +func (s *Store) ListSpawnedAgents(statusFilter, repoRoot string) ([]*SpawnedAgentRecord, error) { + query := `SELECT agent_id, worktree_path, pid, branch, prompt, status, created_at, updated_at, repo_root + FROM spawned_agents WHERE 1=1` + args := []any{} if statusFilter != "" { - rows, err = s.db.Query(` - SELECT agent_id, worktree_path, pid, branch, prompt, status, created_at, updated_at - FROM spawned_agents WHERE status = ? ORDER BY created_at DESC - `, statusFilter) - } else { - rows, err = s.db.Query(` - SELECT agent_id, worktree_path, pid, branch, prompt, status, created_at, updated_at - FROM spawned_agents ORDER BY created_at DESC - `) + query += " AND status = ?" + args = append(args, statusFilter) + } + if repoRoot != "" { + query += " AND repo_root = ?" + args = append(args, repoRoot) } + query += " ORDER BY created_at DESC" + + rows, err := s.db.Query(query, args...) if err != nil { return nil, err } @@ -548,11 +590,11 @@ func (s *Store) DeleteSpawnedAgent(agentID string) error { func (s *Store) scanSpawnedAgent(row *sql.Row) (*SpawnedAgentRecord, error) { var agent SpawnedAgentRecord - var worktreePath, branch, prompt sql.NullString + var worktreePath, branch, prompt, repoRoot sql.NullString var createdAt, updatedAt int64 err := row.Scan(&agent.AgentID, &worktreePath, &agent.PID, &branch, &prompt, - &agent.Status, &createdAt, &updatedAt) + &agent.Status, &createdAt, &updatedAt, &repoRoot) if err != nil { if err == sql.ErrNoRows { return nil, nil @@ -565,17 +607,18 @@ func (s *Store) scanSpawnedAgent(row *sql.Row) (*SpawnedAgentRecord, error) { agent.Prompt = prompt.String agent.CreatedAt = time.Unix(createdAt, 0) agent.UpdatedAt = time.Unix(updatedAt, 0) + agent.RepoRoot = repoRoot.String return &agent, nil } func (s *Store) scanSpawnedAgentRow(rows *sql.Rows) (*SpawnedAgentRecord, error) { var agent SpawnedAgentRecord - var worktreePath, branch, prompt sql.NullString + var worktreePath, branch, prompt, repoRoot sql.NullString var createdAt, updatedAt int64 err := rows.Scan(&agent.AgentID, &worktreePath, &agent.PID, &branch, &prompt, - &agent.Status, &createdAt, &updatedAt) + &agent.Status, &createdAt, &updatedAt, &repoRoot) if err != nil { return nil, err } @@ -585,6 +628,7 @@ func (s *Store) scanSpawnedAgentRow(rows *sql.Rows) (*SpawnedAgentRecord, error) agent.Prompt = prompt.String agent.CreatedAt = time.Unix(createdAt, 0) agent.UpdatedAt = time.Unix(updatedAt, 0) + agent.RepoRoot = repoRoot.String return &agent, nil } diff --git a/internal/daemon/store_test.go b/internal/daemon/store_test.go index cc2b8ac..22cbc7c 100644 --- a/internal/daemon/store_test.go +++ b/internal/daemon/store_test.go @@ -1,10 +1,13 @@ package daemon import ( + "database/sql" "os" "path/filepath" "testing" "time" + + _ "modernc.org/sqlite" ) func setupTestStore(t *testing.T) (*Store, func()) { @@ -141,7 +144,7 @@ func TestListTasks(t *testing.T) { } // List all - all, err := store.ListTasks("", "", 0) + all, err := store.ListTasks("", "", "", 0) if err != nil { t.Fatalf("ListTasks failed: %v", err) } @@ -150,7 +153,7 @@ func TestListTasks(t *testing.T) { } // Filter by status - pending, err := store.ListTasks("pending", "", 0) + pending, err := store.ListTasks("pending", "", "", 0) if err != nil { t.Fatalf("ListTasks failed: %v", err) } @@ -159,7 +162,7 @@ func TestListTasks(t *testing.T) { } // Filter by agent - agentTasks, err := store.ListTasks("", "agent-1", 0) + agentTasks, err := store.ListTasks("", "agent-1", "", 0) if err != nil { t.Fatalf("ListTasks failed: %v", err) } @@ -168,7 +171,7 @@ func TestListTasks(t *testing.T) { } // With limit - limited, err := store.ListTasks("", "", 2) + limited, err := store.ListTasks("", "", "", 2) if err != nil { t.Fatalf("ListTasks failed: %v", err) } @@ -456,7 +459,7 @@ func TestListSpawnedAgents(t *testing.T) { } // List all - all, err := store.ListSpawnedAgents("") + all, err := store.ListSpawnedAgents("", "") if err != nil { t.Fatalf("ListSpawnedAgents failed: %v", err) } @@ -465,7 +468,7 @@ func TestListSpawnedAgents(t *testing.T) { } // Filter by status - running, err := store.ListSpawnedAgents("running") + running, err := store.ListSpawnedAgents("running", "") if err != nil { t.Fatalf("ListSpawnedAgents failed: %v", err) } @@ -473,3 +476,131 @@ func TestListSpawnedAgents(t *testing.T) { t.Errorf("ListSpawnedAgents(running) returned %d agents, want 2", len(running)) } } + +// TestNewStore_MigratesLegacySchema verifies that NewStore can open a database +// created with an older schema version and successfully migrate it. +// This prevents regressions where new schema elements reference columns +// that don't exist until migrations run. +func TestNewStore_MigratesLegacySchema(t *testing.T) { + tempDir, err := os.MkdirTemp("", "mapd-test-*") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tempDir) }() + + dbPath := filepath.Join(tempDir, "mapd.db") + + // Create a database with the "legacy" schema (before GitHub columns were added) + legacySchema := ` +CREATE TABLE IF NOT EXISTS tasks ( + task_id TEXT PRIMARY KEY, + description TEXT NOT NULL, + scope_paths TEXT, + status TEXT DEFAULT 'pending', + assigned_to TEXT, + result TEXT, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); +CREATE INDEX IF NOT EXISTS idx_tasks_assigned_to ON tasks(assigned_to); + +CREATE TABLE IF NOT EXISTS events ( + event_id TEXT PRIMARY KEY, + type TEXT NOT NULL, + payload TEXT, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_events_type ON events(type); +CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at); + +CREATE TABLE IF NOT EXISTS spawned_agents ( + agent_id TEXT PRIMARY KEY, + worktree_path TEXT, + pid INTEGER, + branch TEXT, + prompt TEXT, + status TEXT DEFAULT 'running', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_spawned_agents_status ON spawned_agents(status); +` + + // Create and populate the legacy database + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("open legacy db: %v", err) + } + + if _, err := db.Exec(legacySchema); err != nil { + _ = db.Close() + t.Fatalf("create legacy schema: %v", err) + } + + // Insert a task using the old schema + now := time.Now().Unix() + _, err = db.Exec(`INSERT INTO tasks (task_id, description, scope_paths, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, "legacy-task", "A task from before migration", "[]", "pending", now, now) + if err != nil { + _ = db.Close() + t.Fatalf("insert legacy task: %v", err) + } + + if err := db.Close(); err != nil { + t.Fatalf("close legacy db: %v", err) + } + + // Now open the database with NewStore - this should migrate successfully + store, err := NewStore(tempDir) + if err != nil { + t.Fatalf("NewStore failed to migrate legacy database: %v", err) + } + defer func() { _ = store.Close() }() + + // Verify the legacy task is still accessible + task, err := store.GetTask("legacy-task") + if err != nil { + t.Fatalf("GetTask failed: %v", err) + } + if task == nil { + t.Fatal("legacy task not found after migration") + } + if task.Description != "A task from before migration" { + t.Errorf("Description = %q, want %q", task.Description, "A task from before migration") + } + + // Verify that new columns exist and work by creating a task with GitHub metadata + newTask := &TaskRecord{ + TaskID: "new-task", + Description: "A task with GitHub metadata", + Status: "pending", + GitHubOwner: "testowner", + GitHubRepo: "testrepo", + GitHubIssueNumber: 42, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := store.CreateTask(newTask); err != nil { + t.Fatalf("CreateTask with GitHub metadata failed: %v", err) + } + + // Verify the GitHub metadata was stored correctly + retrieved, err := store.GetTask("new-task") + if err != nil { + t.Fatalf("GetTask failed: %v", err) + } + if retrieved.GitHubOwner != "testowner" { + t.Errorf("GitHubOwner = %q, want %q", retrieved.GitHubOwner, "testowner") + } + if retrieved.GitHubRepo != "testrepo" { + t.Errorf("GitHubRepo = %q, want %q", retrieved.GitHubRepo, "testrepo") + } + if retrieved.GitHubIssueNumber != 42 { + t.Errorf("GitHubIssueNumber = %d, want 42", retrieved.GitHubIssueNumber) + } +} diff --git a/internal/daemon/task.go b/internal/daemon/task.go index 42ac8af..0a1410c 100644 --- a/internal/daemon/task.go +++ b/internal/daemon/task.go @@ -47,6 +47,7 @@ func (r *TaskRouter) SubmitTask(ctx context.Context, req *mapv1.SubmitTaskReques GitHubOwner: req.GetGithubOwner(), GitHubRepo: req.GetGithubRepo(), GitHubIssueNumber: int(req.GetGithubIssueNumber()), + RepoRoot: req.GetRepoRoot(), } if err := r.store.CreateTask(record); err != nil { @@ -99,7 +100,8 @@ func (r *TaskRouter) ProcessPendingTasks() { defer r.mu.Unlock() // Get pending tasks ordered by creation time (oldest first) - pendingTasks, err := r.store.ListTasks("pending", "", 0) + // No repo filter here - process all pending tasks + pendingTasks, err := r.store.ListTasks("pending", "", "", 0) if err != nil { return } @@ -173,8 +175,8 @@ func (r *TaskRouter) GetTask(taskID string) (*mapv1.Task, error) { } // ListTasks retrieves tasks with optional filters -func (r *TaskRouter) ListTasks(statusFilter, agentFilter string, limit int) ([]*mapv1.Task, error) { - records, err := r.store.ListTasks(statusFilter, agentFilter, limit) +func (r *TaskRouter) ListTasks(statusFilter, agentFilter, repoRoot string, limit int) ([]*mapv1.Task, error) { + records, err := r.store.ListTasks(statusFilter, agentFilter, repoRoot, limit) if err != nil { return nil, err } diff --git a/internal/daemon/task_test.go b/internal/daemon/task_test.go index 1146138..2fea7de 100644 --- a/internal/daemon/task_test.go +++ b/internal/daemon/task_test.go @@ -140,7 +140,7 @@ func TestTaskRouter_ListTasks(t *testing.T) { } // List all - all, err := router.ListTasks("", "", 0) + all, err := router.ListTasks("", "", "", 0) if err != nil { t.Fatalf("ListTasks failed: %v", err) } @@ -149,7 +149,7 @@ func TestTaskRouter_ListTasks(t *testing.T) { } // Filter by status - pending, err := router.ListTasks("pending", "", 0) + pending, err := router.ListTasks("pending", "", "", 0) if err != nil { t.Fatalf("ListTasks failed: %v", err) } @@ -158,7 +158,7 @@ func TestTaskRouter_ListTasks(t *testing.T) { } // Filter by agent - agentTasks, err := router.ListTasks("", "agent-1", 0) + agentTasks, err := router.ListTasks("", "agent-1", "", 0) if err != nil { t.Fatalf("ListTasks failed: %v", err) } @@ -167,7 +167,7 @@ func TestTaskRouter_ListTasks(t *testing.T) { } // With limit - limited, err := router.ListTasks("", "", 2) + limited, err := router.ListTasks("", "", "", 2) if err != nil { t.Fatalf("ListTasks failed: %v", err) } diff --git a/internal/daemon/worktree.go b/internal/daemon/worktree.go index e936eea..9d9178e 100644 --- a/internal/daemon/worktree.go +++ b/internal/daemon/worktree.go @@ -25,6 +25,7 @@ type Worktree struct { Path string Branch string CreatedAt time.Time + RepoRoot string // source repository root the worktree was created from } // NewWorktreeManager creates a new worktree manager @@ -48,19 +49,24 @@ func NewWorktreeManager(dataDir string) (*WorktreeManager, error) { }, nil } -// Create creates a new worktree for an agent +// Create creates a new worktree for an agent using the manager's default repo root func (m *WorktreeManager) Create(agentID, branch string) (*Worktree, error) { + return m.CreateFromRepo(agentID, branch, m.repoRoot) +} + +// CreateFromRepo creates a new worktree for an agent from a specific repository +func (m *WorktreeManager) CreateFromRepo(agentID, branch, repoRoot string) (*Worktree, error) { m.mu.Lock() defer m.mu.Unlock() - if m.repoRoot == "" { + if repoRoot == "" { return nil, fmt.Errorf("not in a git repository") } // Use current branch if none specified if branch == "" { var err error - branch, err = getCurrentBranch(m.repoRoot) + branch, err = getCurrentBranch(repoRoot) if err != nil { return nil, fmt.Errorf("get current branch: %w", err) } @@ -75,14 +81,14 @@ func (m *WorktreeManager) Create(agentID, branch string) (*Worktree, error) { // Create the worktree using detached HEAD to avoid branch conflicts // First, get the commit SHA for the branch - commitSHA, err := getCommitSHA(m.repoRoot, branch) + commitSHA, err := getCommitSHA(repoRoot, branch) if err != nil { return nil, fmt.Errorf("get commit SHA for branch %s: %w", branch, err) } // Create worktree at the commit (detached HEAD) cmd := exec.Command("git", "worktree", "add", "--detach", worktreePath, commitSHA) - cmd.Dir = m.repoRoot + cmd.Dir = repoRoot var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { @@ -94,6 +100,7 @@ func (m *WorktreeManager) Create(agentID, branch string) (*Worktree, error) { Path: worktreePath, Branch: branch, CreatedAt: time.Now(), + RepoRoot: repoRoot, } m.worktrees[agentID] = wt diff --git a/proto/map/v1/daemon.pb.go b/proto/map/v1/daemon.pb.go index 3e918d0..780025b 100644 --- a/proto/map/v1/daemon.pb.go +++ b/proto/map/v1/daemon.pb.go @@ -33,8 +33,10 @@ type SubmitTaskRequest struct { GithubOwner string `protobuf:"bytes,4,opt,name=github_owner,json=githubOwner,proto3" json:"github_owner,omitempty"` GithubRepo string `protobuf:"bytes,5,opt,name=github_repo,json=githubRepo,proto3" json:"github_repo,omitempty"` GithubIssueNumber int32 `protobuf:"varint,6,opt,name=github_issue_number,json=githubIssueNumber,proto3" json:"github_issue_number,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Repository root the task belongs to + RepoRoot string `protobuf:"bytes,7,opt,name=repo_root,json=repoRoot,proto3" json:"repo_root,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SubmitTaskRequest) Reset() { @@ -109,6 +111,13 @@ func (x *SubmitTaskRequest) GetGithubIssueNumber() int32 { return 0 } +func (x *SubmitTaskRequest) GetRepoRoot() string { + if x != nil { + return x.RepoRoot + } + return "" +} + // SubmitTaskResponse returns the created task type SubmitTaskResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -162,7 +171,9 @@ type ListTasksRequest struct { // Optional filter by assigned agent AgentFilter string `protobuf:"bytes,2,opt,name=agent_filter,json=agentFilter,proto3" json:"agent_filter,omitempty"` // Limit number of results (0 = no limit) - Limit int32 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"` + Limit int32 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"` + // Optional filter by repo root path (only show tasks for this repo) + RepoRoot string `protobuf:"bytes,4,opt,name=repo_root,json=repoRoot,proto3" json:"repo_root,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -218,6 +229,13 @@ func (x *ListTasksRequest) GetLimit() int32 { return 0 } +func (x *ListTasksRequest) GetRepoRoot() string { + if x != nil { + return x.RepoRoot + } + return "" +} + // ListTasksResponse contains the list of tasks type ListTasksResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -732,8 +750,11 @@ type SpawnAgentRequest struct { // For codex: uses --dangerously-bypass-approvals-and-sandbox // Default: true (agents work autonomously) SkipPermissions bool `protobuf:"varint,7,opt,name=skip_permissions,json=skipPermissions,proto3" json:"skip_permissions,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Working directory - the git repository root to use for worktrees + // If empty, uses daemon's current directory + WorkingDirectory string `protobuf:"bytes,8,opt,name=working_directory,json=workingDirectory,proto3" json:"working_directory,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SpawnAgentRequest) Reset() { @@ -815,6 +836,13 @@ func (x *SpawnAgentRequest) GetSkipPermissions() bool { return false } +func (x *SpawnAgentRequest) GetWorkingDirectory() string { + if x != nil { + return x.WorkingDirectory + } + return "" +} + // SpawnAgentResponse returns info about spawned agents type SpawnAgentResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -870,7 +898,9 @@ type SpawnedAgentInfo struct { CreatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` LogFile string `protobuf:"bytes,6,opt,name=log_file,json=logFile,proto3" json:"log_file,omitempty"` // Agent type: "claude" or "codex" - AgentType string `protobuf:"bytes,7,opt,name=agent_type,json=agentType,proto3" json:"agent_type,omitempty"` + AgentType string `protobuf:"bytes,7,opt,name=agent_type,json=agentType,proto3" json:"agent_type,omitempty"` + // Repository root the agent was created from + RepoRoot string `protobuf:"bytes,8,opt,name=repo_root,json=repoRoot,proto3" json:"repo_root,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -954,6 +984,13 @@ func (x *SpawnedAgentInfo) GetAgentType() string { return "" } +func (x *SpawnedAgentInfo) GetRepoRoot() string { + if x != nil { + return x.RepoRoot + } + return "" +} + // KillAgentRequest requests termination of a spawned agent type KillAgentRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1063,7 +1100,9 @@ func (x *KillAgentResponse) GetMessage() string { // ListSpawnedAgentsRequest requests list of spawned agents type ListSpawnedAgentsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // Optional filter by repo root path (only show agents for this repo) + RepoRoot string `protobuf:"bytes,1,opt,name=repo_root,json=repoRoot,proto3" json:"repo_root,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1098,6 +1137,13 @@ func (*ListSpawnedAgentsRequest) Descriptor() ([]byte, []int) { return file_map_v1_daemon_proto_rawDescGZIP(), []int{18} } +func (x *ListSpawnedAgentsRequest) GetRepoRoot() string { + if x != nil { + return x.RepoRoot + } + return "" +} + // ListSpawnedAgentsResponse returns spawned agents type ListSpawnedAgentsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1243,7 +1289,9 @@ func (x *RespawnAgentResponse) GetMessage() string { // ListWorktreesRequest requests list of worktrees type ListWorktreesRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // Optional filter by repo root path (only show worktrees for this repo) + RepoRoot string `protobuf:"bytes,1,opt,name=repo_root,json=repoRoot,proto3" json:"repo_root,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1278,6 +1326,13 @@ func (*ListWorktreesRequest) Descriptor() ([]byte, []int) { return file_map_v1_daemon_proto_rawDescGZIP(), []int{22} } +func (x *ListWorktreesRequest) GetRepoRoot() string { + if x != nil { + return x.RepoRoot + } + return "" +} + // ListWorktreesResponse returns worktree info type ListWorktreesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1325,11 +1380,13 @@ func (x *ListWorktreesResponse) GetWorktrees() []*WorktreeInfo { // WorktreeInfo describes a git worktree type WorktreeInfo struct { - state protoimpl.MessageState `protogen:"open.v1"` - AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` - Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` - Branch string `protobuf:"bytes,3,opt,name=branch,proto3" json:"branch,omitempty"` - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Branch string `protobuf:"bytes,3,opt,name=branch,proto3" json:"branch,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // Repository root the worktree was created from + RepoRoot string `protobuf:"bytes,5,opt,name=repo_root,json=repoRoot,proto3" json:"repo_root,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1392,6 +1449,13 @@ func (x *WorktreeInfo) GetCreatedAt() *timestamppb.Timestamp { return nil } +func (x *WorktreeInfo) GetRepoRoot() string { + if x != nil { + return x.RepoRoot + } + return "" +} + // CleanupWorktreesRequest requests worktree cleanup type CleanupWorktreesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1700,7 +1764,7 @@ var File_map_v1_daemon_proto protoreflect.FileDescriptor const file_map_v1_daemon_proto_rawDesc = "" + "\n" + - "\x13map/v1/daemon.proto\x12\x06map.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x12map/v1/types.proto\"\xf2\x01\n" + + "\x13map/v1/daemon.proto\x12\x06map.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x12map/v1/types.proto\"\x8f\x02\n" + "\x11SubmitTaskRequest\x12 \n" + "\vdescription\x18\x01 \x01(\tR\vdescription\x12\x1f\n" + "\vscope_paths\x18\x02 \x03(\tR\n" + @@ -1709,13 +1773,15 @@ const file_map_v1_daemon_proto_rawDesc = "" + "\fgithub_owner\x18\x04 \x01(\tR\vgithubOwner\x12\x1f\n" + "\vgithub_repo\x18\x05 \x01(\tR\n" + "githubRepo\x12.\n" + - "\x13github_issue_number\x18\x06 \x01(\x05R\x11githubIssueNumber\"6\n" + + "\x13github_issue_number\x18\x06 \x01(\x05R\x11githubIssueNumber\x12\x1b\n" + + "\trepo_root\x18\a \x01(\tR\brepoRoot\"6\n" + "\x12SubmitTaskResponse\x12 \n" + - "\x04task\x18\x01 \x01(\v2\f.map.v1.TaskR\x04task\"\x84\x01\n" + + "\x04task\x18\x01 \x01(\v2\f.map.v1.TaskR\x04task\"\xa1\x01\n" + "\x10ListTasksRequest\x127\n" + "\rstatus_filter\x18\x01 \x01(\x0e2\x12.map.v1.TaskStatusR\fstatusFilter\x12!\n" + "\fagent_filter\x18\x02 \x01(\tR\vagentFilter\x12\x14\n" + - "\x05limit\x18\x03 \x01(\x05R\x05limit\"7\n" + + "\x05limit\x18\x03 \x01(\x05R\x05limit\x12\x1b\n" + + "\trepo_root\x18\x04 \x01(\tR\brepoRoot\"7\n" + "\x11ListTasksResponse\x12\"\n" + "\x05tasks\x18\x01 \x03(\v2\f.map.v1.TaskR\x05tasks\")\n" + "\x0eGetTaskRequest\x12\x17\n" + @@ -1743,7 +1809,7 @@ const file_map_v1_daemon_proto_rawDesc = "" + "typeFilter\x12!\n" + "\fagent_filter\x18\x02 \x01(\tR\vagentFilter\x12\x1f\n" + "\vtask_filter\x18\x03 \x01(\tR\n" + - "taskFilter\"\xe7\x01\n" + + "taskFilter\"\x94\x02\n" + "\x11SpawnAgentRequest\x12\x14\n" + "\x05count\x18\x01 \x01(\x05R\x05count\x12\x16\n" + "\x06branch\x18\x02 \x01(\tR\x06branch\x12!\n" + @@ -1753,9 +1819,10 @@ const file_map_v1_daemon_proto_rawDesc = "" + "\x06prompt\x18\x05 \x01(\tR\x06prompt\x12\x1d\n" + "\n" + "agent_type\x18\x06 \x01(\tR\tagentType\x12)\n" + - "\x10skip_permissions\x18\a \x01(\bR\x0fskipPermissions\"F\n" + + "\x10skip_permissions\x18\a \x01(\bR\x0fskipPermissions\x12+\n" + + "\x11working_directory\x18\b \x01(\tR\x10workingDirectory\"F\n" + "\x12SpawnAgentResponse\x120\n" + - "\x06agents\x18\x01 \x03(\v2\x18.map.v1.SpawnedAgentInfoR\x06agents\"\xf1\x01\n" + + "\x06agents\x18\x01 \x03(\v2\x18.map.v1.SpawnedAgentInfoR\x06agents\"\x8e\x02\n" + "\x10SpawnedAgentInfo\x12\x19\n" + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12#\n" + "\rworktree_path\x18\x02 \x01(\tR\fworktreePath\x12\x10\n" + @@ -1765,30 +1832,34 @@ const file_map_v1_daemon_proto_rawDesc = "" + "created_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x19\n" + "\blog_file\x18\x06 \x01(\tR\alogFile\x12\x1d\n" + "\n" + - "agent_type\x18\a \x01(\tR\tagentType\"C\n" + + "agent_type\x18\a \x01(\tR\tagentType\x12\x1b\n" + + "\trepo_root\x18\b \x01(\tR\brepoRoot\"C\n" + "\x10KillAgentRequest\x12\x19\n" + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x14\n" + "\x05force\x18\x02 \x01(\bR\x05force\"G\n" + "\x11KillAgentResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\"\x1a\n" + - "\x18ListSpawnedAgentsRequest\"M\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"7\n" + + "\x18ListSpawnedAgentsRequest\x12\x1b\n" + + "\trepo_root\x18\x01 \x01(\tR\brepoRoot\"M\n" + "\x19ListSpawnedAgentsResponse\x120\n" + "\x06agents\x18\x01 \x03(\v2\x18.map.v1.SpawnedAgentInfoR\x06agents\"0\n" + "\x13RespawnAgentRequest\x12\x19\n" + "\bagent_id\x18\x01 \x01(\tR\aagentId\"J\n" + "\x14RespawnAgentResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\"\x16\n" + - "\x14ListWorktreesRequest\"K\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"3\n" + + "\x14ListWorktreesRequest\x12\x1b\n" + + "\trepo_root\x18\x01 \x01(\tR\brepoRoot\"K\n" + "\x15ListWorktreesResponse\x122\n" + - "\tworktrees\x18\x01 \x03(\v2\x14.map.v1.WorktreeInfoR\tworktrees\"\x90\x01\n" + + "\tworktrees\x18\x01 \x03(\v2\x14.map.v1.WorktreeInfoR\tworktrees\"\xad\x01\n" + "\fWorktreeInfo\x12\x19\n" + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x12\n" + "\x04path\x18\x02 \x01(\tR\x04path\x12\x16\n" + "\x06branch\x18\x03 \x01(\tR\x06branch\x129\n" + "\n" + - "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"F\n" + + "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x1b\n" + + "\trepo_root\x18\x05 \x01(\tR\brepoRoot\"F\n" + "\x17CleanupWorktreesRequest\x12\x19\n" + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x10\n" + "\x03all\x18\x02 \x01(\bR\x03all\"d\n" + diff --git a/proto/map/v1/daemon.proto b/proto/map/v1/daemon.proto index 985b701..7b01366 100644 --- a/proto/map/v1/daemon.proto +++ b/proto/map/v1/daemon.proto @@ -45,6 +45,8 @@ message SubmitTaskRequest { string github_owner = 4; string github_repo = 5; int32 github_issue_number = 6; + // Repository root the task belongs to + string repo_root = 7; } // SubmitTaskResponse returns the created task @@ -60,6 +62,8 @@ message ListTasksRequest { string agent_filter = 2; // Limit number of results (0 = no limit) int32 limit = 3; + // Optional filter by repo root path (only show tasks for this repo) + string repo_root = 4; } // ListTasksResponse contains the list of tasks @@ -141,6 +145,9 @@ message SpawnAgentRequest { // For codex: uses --dangerously-bypass-approvals-and-sandbox // Default: true (agents work autonomously) bool skip_permissions = 7; + // Working directory - the git repository root to use for worktrees + // If empty, uses daemon's current directory + string working_directory = 8; } // SpawnAgentResponse returns info about spawned agents @@ -158,6 +165,8 @@ message SpawnedAgentInfo { string log_file = 6; // Agent type: "claude" or "codex" string agent_type = 7; + // Repository root the agent was created from + string repo_root = 8; } // KillAgentRequest requests termination of a spawned agent @@ -174,7 +183,10 @@ message KillAgentResponse { } // ListSpawnedAgentsRequest requests list of spawned agents -message ListSpawnedAgentsRequest {} +message ListSpawnedAgentsRequest { + // Optional filter by repo root path (only show agents for this repo) + string repo_root = 1; +} // ListSpawnedAgentsResponse returns spawned agents message ListSpawnedAgentsResponse { @@ -195,7 +207,10 @@ message RespawnAgentResponse { // --- Worktree Messages --- // ListWorktreesRequest requests list of worktrees -message ListWorktreesRequest {} +message ListWorktreesRequest { + // Optional filter by repo root path (only show worktrees for this repo) + string repo_root = 1; +} // ListWorktreesResponse returns worktree info message ListWorktreesResponse { @@ -208,6 +223,8 @@ message WorktreeInfo { string path = 2; string branch = 3; google.protobuf.Timestamp created_at = 4; + // Repository root the worktree was created from + string repo_root = 5; } // CleanupWorktreesRequest requests worktree cleanup