Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ This creates two binaries in `bin/`:
| `map task show <id>` | Show detailed task information |
| `map task cancel <id>` | Cancel a pending or in-progress task |
| `map task sync gh-project <name>` | Sync tasks from a GitHub Project |
| `map task my-task` | Show the current task for this agent (by working directory) |
| `map task input-needed <id> <question>` | Request user input via GitHub issue |

## Spawning Agents

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <task-id> "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:
Expand All @@ -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

Expand Down
5 changes: 3 additions & 2 deletions internal/cli/agent_merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/agent_watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 3 additions & 1 deletion internal/cli/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
35 changes: 24 additions & 11 deletions internal/cli/spawn.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"context"
"fmt"
"os"
"strings"
"time"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
16 changes: 15 additions & 1 deletion internal/cli/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"context"
"fmt"
"os/exec"
"strings"
"time"

Expand All @@ -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"},
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"
}
Expand Down
111 changes: 111 additions & 0 deletions internal/cli/task_input.go
Original file line number Diff line number Diff line change
@@ -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 <task-id> <question>",
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
}
Loading