Sitelet https://github.com/pmarsceill/mapcli/commit/5d2cb28021ae22ab3211bf38588f78470a23858e
Skip to content

Commit 5d2cb28

Browse files
authored
Merge pull request #22 from pmarsceill/feat/bidirectional-github-issue-sync
feat: add bidirectional GitHub issue sync for agent input
2 parents 450a245 + 5d15d95 commit 5d2cb28

25 files changed

Lines changed: 2483 additions & 297 deletions

‎README.md‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@ This creates two binaries in `bin/`:
197197
| `map task show <id>` | Show detailed task information |
198198
| `map task cancel <id>` | Cancel a pending or in-progress task |
199199
| `map task sync gh-project <name>` | Sync tasks from a GitHub Project |
200+
| `map task my-task` | Show the current task for this agent (by working directory) |
201+
| `map task input-needed <id> <question>` | Request user input via GitHub issue |
200202

201203
## Spawning Agents
202204

@@ -321,6 +323,8 @@ MAP includes a task routing system for distributing work to agents.
321323

322324
Tasks follow this lifecycle: `PENDING → OFFERED → ACCEPTED → IN_PROGRESS → COMPLETED/FAILED/CANCELLED`
323325

326+
Tasks can also enter `WAITING_INPUT` status when an agent needs user input. Once the user responds, the task returns to `IN_PROGRESS`.
327+
324328
### Task Commands
325329

326330
```bash
@@ -382,6 +386,38 @@ map task sync gh-project "My Project" --limit 5
382386
| `--limit` | `10` | Maximum number of items to sync |
383387
| `--dry-run` | `false` | Preview without creating tasks or updating GitHub |
384388

389+
### Bidirectional GitHub Issue Sync
390+
391+
When tasks are synced from GitHub Projects, MAP tracks the originating issue and enables bidirectional communication:
392+
393+
**Automatic Input Detection:**
394+
- The daemon monitors agent tmux sessions for signs that the agent is waiting for user input
395+
- When detected (agent idle + question pattern in output), the question is automatically posted to the GitHub issue
396+
- Users can respond directly on the GitHub issue
397+
- Responses are automatically delivered back to the agent's session
398+
399+
**How it works:**
400+
1. Sync issues with `map task sync gh-project "Project"` - GitHub metadata is stored with each task
401+
2. Agent works on the task and asks a question (detected automatically)
402+
3. Question is posted to the GitHub issue with prefix `**My agent needs more input:**`
403+
4. User responds on GitHub
404+
5. Response is delivered to the agent's tmux session
405+
6. Agent continues working
406+
407+
**Manual input requests:**
408+
409+
Agents can also explicitly request input:
410+
```bash
411+
# From within an agent session, request user input
412+
map task input-needed <task-id> "What error format should I use?"
413+
```
414+
415+
**Agent introspection:**
416+
```bash
417+
# Find the current task for this working directory
418+
map task my-task
419+
```
420+
385421
## Event Streaming
386422

387423
Watch real-time events from the daemon:
@@ -391,7 +427,7 @@ Watch real-time events from the daemon:
391427
map watch
392428
```
393429

394-
Events include task lifecycle changes (created, offered, accepted, started, completed, failed, cancelled) and agent status updates.
430+
Events include task lifecycle changes (created, offered, accepted, started, completed, failed, cancelled, waiting_input, input_received) and agent status updates.
395431

396432
### Agent Create Options
397433

‎internal/cli/agent_merge.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@ func runAgentMerge(cmd *cobra.Command, args []string) error {
5555
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
5656
defer cancel()
5757

58-
// Find the agent
59-
agents, err := c.ListSpawnedAgents(ctx)
58+
// Find the agent in current repo
59+
repoRoot := getRepoRoot()
60+
agents, err := c.ListSpawnedAgents(ctx, repoRoot)
6061
if err != nil {
6162
return fmt.Errorf("list agents: %w", err)
6263
}

‎internal/cli/agent_watch.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ func runAgentWatch(cmd *cobra.Command, args []string) error {
5454
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
5555
defer cancel()
5656

57-
// Get list of spawned agents
58-
agents, err := c.ListSpawnedAgents(ctx)
57+
// Get list of spawned agents for current repo
58+
repoRoot := getRepoRoot()
59+
agents, err := c.ListSpawnedAgents(ctx, repoRoot)
5960
if err != nil {
6061
return fmt.Errorf("list agents: %w", err)
6162
}

‎internal/cli/agents.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ func runAgents(cmd *cobra.Command, args []string) error {
3232
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
3333
defer cancel()
3434

35-
agents, err := c.ListSpawnedAgents(ctx)
35+
// Filter by current repo
36+
repoRoot := getRepoRoot()
37+
agents, err := c.ListSpawnedAgents(ctx, repoRoot)
3638
if err != nil {
3739
return fmt.Errorf("list agents: %w", err)
3840
}

‎internal/cli/spawn.go‎

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"context"
55
"fmt"
6+
"os"
67
"strings"
78
"time"
89

@@ -130,17 +131,24 @@ func runAgentCreate(cmd *cobra.Command, args []string) error {
130131
// no-worktree overrides worktree
131132
useWorktree := worktree && !noWorktree
132133

134+
// Get current working directory to pass to daemon
135+
cwd, err := os.Getwd()
136+
if err != nil {
137+
return fmt.Errorf("get working directory: %w", err)
138+
}
139+
133140
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
134141
defer cancel()
135142

136143
req := &mapv1.SpawnAgentRequest{
137-
Count: int32(count),
138-
Branch: branch,
139-
UseWorktree: useWorktree,
140-
NamePrefix: name,
141-
Prompt: prompt,
142-
AgentType: agentType,
143-
SkipPermissions: skipPermissions,
144+
Count: int32(count),
145+
Branch: branch,
146+
UseWorktree: useWorktree,
147+
NamePrefix: name,
148+
Prompt: prompt,
149+
AgentType: agentType,
150+
SkipPermissions: skipPermissions,
151+
WorkingDirectory: cwd,
144152
}
145153

146154
resp, err := c.SpawnAgent(ctx, req)
@@ -186,7 +194,9 @@ func runAgentList(cmd *cobra.Command, args []string) error {
186194
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
187195
defer cancel()
188196

189-
agents, err := c.ListSpawnedAgents(ctx)
197+
// Filter by current repo
198+
repoRoot := getRepoRoot()
199+
agents, err := c.ListSpawnedAgents(ctx, repoRoot)
190200
if err != nil {
191201
return fmt.Errorf("list agents: %w", err)
192202
}
@@ -225,7 +235,9 @@ func runAgentKill(cmd *cobra.Command, args []string) error {
225235

226236
// Handle --all flag
227237
if killAll {
228-
agents, err := c.ListSpawnedAgents(ctx)
238+
// Kill all agents in current repo
239+
repoRoot := getRepoRoot()
240+
agents, err := c.ListSpawnedAgents(ctx, repoRoot)
229241
if err != nil {
230242
return fmt.Errorf("list agents: %w", err)
231243
}
@@ -319,9 +331,10 @@ func runAgentRespawn(cmd *cobra.Command, args []string) error {
319331
return nil
320332
}
321333

322-
// resolveAgentID finds an agent by exact or partial ID match
334+
// resolveAgentID finds an agent by exact or partial ID match in the current repo
323335
func resolveAgentID(ctx context.Context, c *client.Client, agentID string) (string, error) {
324-
agents, err := c.ListSpawnedAgents(ctx)
336+
repoRoot := getRepoRoot()
337+
agents, err := c.ListSpawnedAgents(ctx, repoRoot)
325338
if err != nil {
326339
return "", fmt.Errorf("list agents: %w", err)
327340
}

‎internal/cli/task.go‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"context"
55
"fmt"
6+
"os/exec"
67
"strings"
78
"time"
89

@@ -11,6 +12,15 @@ import (
1112
"github.com/spf13/cobra"
1213
)
1314

15+
// getRepoRoot returns the git repository root for the current directory
16+
func getRepoRoot() string {
17+
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
18+
if err != nil {
19+
return ""
20+
}
21+
return strings.TrimSpace(string(out))
22+
}
23+
1424
var taskCmd = &cobra.Command{
1525
Use: "task",
1626
Aliases: []string{"tasks", "t"},
@@ -98,7 +108,9 @@ func runTaskList(cmd *cobra.Command, args []string) error {
98108
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
99109
defer cancel()
100110

101-
tasks, err := c.ListTasks(ctx, taskLimit)
111+
// Filter by current repo
112+
repoRoot := getRepoRoot()
113+
tasks, err := c.ListTasks(ctx, taskLimit, repoRoot)
102114
if err != nil {
103115
return fmt.Errorf("list tasks: %w", err)
104116
}
@@ -201,6 +213,8 @@ func taskStatusString(s mapv1.TaskStatus) string {
201213
return "failed"
202214
case mapv1.TaskStatus_TASK_STATUS_CANCELLED:
203215
return "cancelled"
216+
case mapv1.TaskStatus_TASK_STATUS_WAITING_INPUT:
217+
return "waiting_input"
204218
default:
205219
return "unknown"
206220
}

‎internal/cli/task_input.go‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"strings"
8+
"time"
9+
10+
"github.com/pmarsceill/mapcli/internal/client"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var taskInputNeededCmd = &cobra.Command{
15+
Use: "input-needed <task-id> <question>",
16+
Short: "Request user input for a task via GitHub issue",
17+
Long: `Signal that an agent needs user input by posting a comment to the originating GitHub issue.
18+
19+
This command:
20+
1. Posts a comment to the GitHub issue with the question
21+
2. Sets the task status to WAITING_INPUT
22+
3. The daemon will poll for responses and deliver them to the agent
23+
24+
The task must have originated from a GitHub issue (via 'map task sync gh-project').`,
25+
Args: cobra.MinimumNArgs(2),
26+
RunE: runTaskInputNeeded,
27+
}
28+
29+
var taskMyTaskCmd = &cobra.Command{
30+
Use: "my-task",
31+
Short: "Show the current task for this agent",
32+
Long: `Look up the current task by matching the working directory to an agent worktree.
33+
34+
This is useful for agents to introspect their assigned task.`,
35+
RunE: runTaskMyTask,
36+
}
37+
38+
func init() {
39+
taskCmd.AddCommand(taskInputNeededCmd)
40+
taskCmd.AddCommand(taskMyTaskCmd)
41+
}
42+
43+
func runTaskInputNeeded(cmd *cobra.Command, args []string) error {
44+
taskID := args[0]
45+
question := strings.Join(args[1:], " ")
46+
47+
c, err := client.New(getSocketPath())
48+
if err != nil {
49+
return fmt.Errorf("connect to daemon: %w", err)
50+
}
51+
defer func() { _ = c.Close() }()
52+
53+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
54+
defer cancel()
55+
56+
resp, err := c.RequestInput(ctx, taskID, question)
57+
if err != nil {
58+
return fmt.Errorf("request input: %w", err)
59+
}
60+
61+
if !resp.Success {
62+
return fmt.Errorf("%s", resp.Message)
63+
}
64+
65+
fmt.Println(resp.Message)
66+
return nil
67+
}
68+
69+
func runTaskMyTask(cmd *cobra.Command, args []string) error {
70+
cwd, err := os.Getwd()
71+
if err != nil {
72+
return fmt.Errorf("get working directory: %w", err)
73+
}
74+
75+
c, err := client.New(getSocketPath())
76+
if err != nil {
77+
return fmt.Errorf("connect to daemon: %w", err)
78+
}
79+
defer func() { _ = c.Close() }()
80+
81+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
82+
defer cancel()
83+
84+
task, err := c.GetCurrentTask(ctx, cwd)
85+
if err != nil {
86+
return fmt.Errorf("get current task: %w", err)
87+
}
88+
89+
if task == nil {
90+
fmt.Println("no task found for this working directory")
91+
return nil
92+
}
93+
94+
fmt.Printf("Task ID: %s\n", task.TaskId)
95+
fmt.Printf("Status: %s\n", taskStatusString(task.Status))
96+
fmt.Printf("Description: %s\n", task.Description)
97+
fmt.Printf("Assigned To: %s\n", valueOrDash(task.AssignedTo))
98+
99+
if task.GithubSource != nil {
100+
fmt.Printf("GitHub: %s/%s#%d\n",
101+
task.GithubSource.Owner,
102+
task.GithubSource.Repo,
103+
task.GithubSource.IssueNumber)
104+
}
105+
106+
if task.WaitingInputQuestion != "" {
107+
fmt.Printf("\nWaiting for input:\n%s\n", task.WaitingInputQuestion)
108+
}
109+
110+
return nil
111+
}

0 commit comments

Comments
 (0)