diff --git a/README.md b/README.md index 12238d1..f875eac 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,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 @@ -321,6 +323,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 @@ -382,6 +386,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: @@ -391,7 +427,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/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 da16a9d..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) } @@ -201,6 +213,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..891bcc9 --- /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(getSocketPath()) + 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(getSocketPath()) + 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 9c86d6b..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 } @@ -187,9 +226,13 @@ 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) + repoRoot := getRepoRoot() + task, err := c.SubmitTaskWithGitHub(ctx, description, nil, owner, repo, int32(item.Content.Number), repoRoot) cancel() if err != nil { @@ -199,6 +242,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 { @@ -223,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 { @@ -232,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) } @@ -241,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 } } @@ -303,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() } @@ -324,3 +453,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/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 5d994a6..4c29e5e 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -59,10 +59,27 @@ 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, 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 + } + return resp.Task, nil +} + // 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 @@ -92,6 +109,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{}) @@ -124,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 } @@ -142,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 new file mode 100644 index 0000000..04c8aae --- /dev/null +++ b/internal/daemon/github_poller.go @@ -0,0 +1,369 @@ +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 +} + +// 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 string `json:"id"` // GraphQL node ID (e.g., "IC_kwDOPqDJoM7iErGE") + Body string `json:"body"` + Author ghCommentAuthor `json:"author"` + CreatedAt string `json:"createdAt"` +} + +// ghIssueComments is the response from gh issue view --json comments +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 = 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 { + 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 and check for responses + waitingTasks, err := p.store.ListTasksWaitingInput() + if err != nil { + log.Printf("github poller: failed to list waiting tasks: %v", err) + } else { + for _, task := range waitingTasks { + 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) + } + } +} + +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 != "" && c.ID == task.LastCommentID { + 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.Login) + + // 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, 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) 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") + } + + 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(tmuxPasteDelay) + + // 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 second 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..f254577 --- /dev/null +++ b/internal/daemon/input_monitor.go @@ -0,0 +1,319 @@ +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() + 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) { + // 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..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 @@ -212,8 +215,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,10 +234,27 @@ 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(tmuxPasteDelay) + + // 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) } @@ -418,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, } } @@ -451,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", " ") @@ -471,12 +494,25 @@ 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(tmuxPasteDelay) + + // 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 64a5866..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" @@ -25,13 +27,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 +77,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 +120,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 +134,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() @@ -175,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 } @@ -283,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 != "" { @@ -297,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 { @@ -325,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 != "" { @@ -345,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) @@ -396,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 @@ -438,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, }) } @@ -477,6 +535,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 +668,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..1b9617d 100644 --- a/internal/daemon/store.go +++ b/internal/daemon/store.go @@ -27,6 +27,15 @@ 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 + // Repository root this task belongs to + RepoRoot string } // EventRecord represents an event in the database @@ -47,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 = ` @@ -59,11 +70,19 @@ 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, + 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); +-- Note: idx_tasks_github is created in migrate() to support existing databases CREATE TABLE IF NOT EXISTS events ( event_id TEXT PRIMARY KEY, @@ -83,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); @@ -113,7 +133,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 +148,32 @@ 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", + "ALTER TABLE tasks ADD COLUMN repo_root TEXT", + "ALTER TABLE spawned_agents ADD COLUMN repo_root TEXT", + } + + for _, m := range migrations { + // Ignore errors - column may already exist + _, _ = s.db.Exec(m) + } + + // 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 +} + // --- Task Operations --- // CreateTask creates a new task @@ -130,11 +183,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, repo_root) + 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, task.RepoRoot) return err } @@ -142,7 +203,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, repo_root FROM tasks WHERE task_id = ? `, taskID) @@ -150,9 +212,11 @@ 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{}{} +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, repo_root + FROM tasks WHERE 1=1` + args := []any{} if statusFilter != "" { query += " AND status = ?" @@ -162,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" @@ -195,12 +263,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 = ?, repo_root = ? 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.RepoRoot, task.TaskID) return err } @@ -221,14 +298,110 @@ 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, 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 + `) + 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() +} + +// 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() + _, 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, repo_root + 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, repo_root + 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 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) + &assignedTo, &result, &taskError, &createdAt, &updatedAt, + &githubOwner, &githubRepo, &githubIssueNumber, &lastCommentID, + &waitingInputQuestion, &waitingInputSince, &repoRoot) if err != nil { if err == sql.ErrNoRows { return nil, nil @@ -244,6 +417,15 @@ 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.Valid && waitingInputSince.Int64 > 0 { + task.WaitingInputSince = time.Unix(waitingInputSince.Int64, 0) + } + task.RepoRoot = repoRoot.String return &task, nil } @@ -252,10 +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, 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) + &assignedTo, &result, &taskError, &createdAt, &updatedAt, + &githubOwner, &githubRepo, &githubIssueNumber, &lastCommentID, + &waitingInputQuestion, &waitingInputSince, &repoRoot) if err != nil { return nil, err } @@ -268,6 +454,15 @@ 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.Valid && waitingInputSince.Int64 > 0 { + task.WaitingInputSince = time.Unix(waitingInputSince.Int64, 0) + } + task.RepoRoot = repoRoot.String return &task, nil } @@ -327,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 } @@ -394,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 @@ -411,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 } @@ -431,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 dd7b903..0a1410c 100644 --- a/internal/daemon/task.go +++ b/internal/daemon/task.go @@ -36,14 +36,18 @@ 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()), + RepoRoot: req.GetRepoRoot(), } if err := r.store.CreateTask(record); err != nil { @@ -59,6 +63,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, "") @@ -87,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 } @@ -161,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 } @@ -239,6 +253,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 +295,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/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 c441d90..780025b 100644 --- a/proto/map/v1/daemon.pb.go +++ b/proto/map/v1/daemon.pb.go @@ -29,6 +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"` + // 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"` + // 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 } @@ -84,6 +90,34 @@ 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 +} + +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"` @@ -137,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 } @@ -193,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"` @@ -707,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() { @@ -790,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"` @@ -845,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 } @@ -929,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"` @@ -1038,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 } @@ -1073,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"` @@ -1218,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 } @@ -1253,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"` @@ -1300,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 } @@ -1367,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"` @@ -1475,22 +1564,224 @@ 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\"\x8f\x02\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\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" + @@ -1518,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" + @@ -1528,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" + @@ -1540,43 +1832,59 @@ 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" + "\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 +1908,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 +1937,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 +2010,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..7b01366 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,12 @@ 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; + // Repository root the task belongs to + string repo_root = 7; } // SubmitTaskResponse returns the created task @@ -54,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 @@ -135,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 @@ -152,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 @@ -168,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 { @@ -189,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 { @@ -202,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 @@ -217,3 +240,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