diff --git a/commands/boss.check.md b/commands/boss.check.md index 2beb3f2..8b18b4f 100644 --- a/commands/boss.check.md +++ b/commands/boss.check.md @@ -1,4 +1,4 @@ -STOP. This is a mechanical status sync. Do NOT plan or analyze. Execute these 3 steps literally, then STOP. +STOP. This is a mechanical status sync. Do NOT plan or analyze. Execute these 4 steps literally, then STOP. **Parse `$ARGUMENTS`:** `$ARGUMENTS` contains two words separated by a space. The FIRST word is your agent name. The SECOND word is the space name. Example: if `$ARGUMENTS` is `Overlord sdk-backend-replacement`, then your agent name is `Overlord` and the space name is `sdk-backend-replacement`. @@ -10,14 +10,20 @@ If `$ARGUMENTS` contains only ONE word, it is the space name. Run `tmux display- curl -s http://localhost:8899/spaces/SPACE_NAME/raw ``` -Replace SPACE_NAME with the space name from `$ARGUMENTS`. Scan for anything addressed to you. Do NOT analyze other agents. +Replace SPACE_NAME with the space name from `$ARGUMENTS`. Scan your section for: +- **Messages** — look for a `#### Messages` section under your agent name. These are messages from the boss or other agents. Note any instructions or questions. +- **Standing orders** — anything addressed to you in shared contracts. -**Important rule**: Always use `curl`, never use Fetch tool. Fetch will *not* work on localhost. **Always** use curl. This is important! +Do NOT analyze other agents' sections. + +**Important rule**: Always use `curl`, never use Fetch tool. Fetch will *not* work on localhost. **Always** use curl. This is important! ## Step 2: Write your status JSON and POST it Create `/tmp/boss_checkin.json` reflecting your CURRENT state. Do not change your work — just report what you are doing right now. +If you found messages in Step 1, acknowledge them in your `items` array (e.g. `"Received message from boss: "`). + ```bash cat > /tmp/boss_checkin.json << 'CHECKIN' { @@ -47,6 +53,12 @@ curl -s -X POST http://localhost:8899/spaces/SPACE_NAME/agent/AGENT_NAME \ Replace SPACE_NAME and AGENT_NAME with the values from `$ARGUMENTS`. You MUST see `accepted for` in the response. If you do not, something is wrong — retry once. -## Step 3: STOP +## Step 3: Act on messages + +If you found messages in Step 1 that contain instructions or task assignments, begin working on them now. If a message asks a question, answer it in your next status update. + +If there were no messages, or messages were purely informational, skip this step. + +## Step 4: STOP -Do not start any work. Do not analyze the blackboard. Do not make plans. STOP HERE. +If you had no actionable messages, STOP HERE. Do not start any work. Do not analyze the blackboard. Do not make plans. diff --git a/commands/boss.ignite.md b/commands/boss.ignite.md index 4c16e73..fd79815 100644 --- a/commands/boss.ignite.md +++ b/commands/boss.ignite.md @@ -44,3 +44,12 @@ Using the protocol and template from Step 2, post your initial status to your ch - **Tag questions with `[?BOSS]`** when you need the human to make a decision. - **Post to your own channel only** — the server rejects cross-channel posts. - **Do NOT include `tmux_session` in your POST** — it was pre-registered in Step 2 and is sticky. +- **Check for messages** — when you read `/raw`, look for a `#### Messages` section under your agent name. These are messages from the boss or other agents sent via the dashboard. Acknowledge them in your next status POST and act on any instructions. +- **Send messages to other agents** — to message another agent, POST to their message endpoint: + ```bash + curl -s -X POST http://localhost:8899/spaces/SPACE_NAME/agent/OTHER_AGENT/message \ + -H 'Content-Type: application/json' \ + -H 'X-Agent-Name: YOUR_NAME' \ + -d '{"message": "your message here"}' + ``` + The message will appear in their `#### Messages` section on the next check-in. diff --git a/internal/coordinator/protocol.md b/internal/coordinator/protocol.md index f150a3a..26b0f16 100644 --- a/internal/coordinator/protocol.md +++ b/internal/coordinator/protocol.md @@ -12,6 +12,7 @@ Space: `{SPACE}` |--------|---------| | Post (JSON) | `curl -s -X POST http://localhost:8899/spaces/{SPACE}/agent/{name} -H 'Content-Type: application/json' -H 'X-Agent-Name: {name}' -d '{"status":"...","summary":"...","items":[...]}'` | | Post (text) | `curl -s -X POST http://localhost:8899/spaces/{SPACE}/agent/{name} -H 'Content-Type: text/plain' -H 'X-Agent-Name: {name}' --data-binary @/tmp/my_update.md` | +| Send message | `curl -s -X POST http://localhost:8899/spaces/{SPACE}/agent/{target}/message -H 'Content-Type: application/json' -H 'X-Agent-Name: {sender}' -d '{"message":"..."}'` | | Read section | `curl -s http://localhost:8899/spaces/{SPACE}/agent/{name}` | | Read full doc | `curl -s http://localhost:8899/spaces/{SPACE}/raw` | | Browser | `http://localhost:8899/spaces/{SPACE}/` (polls every 3s) | @@ -28,7 +29,8 @@ Space: `{SPACE}` > **IMPORTANT: `repo_url` is REQUIRED in your first POST.** Without it, PR links in the dashboard are broken. Find it with `git remote get-url origin` and include it as `"repo_url": "https://..."`. You only need to send it once — the server remembers it. 8. **Register your tmux session.** Include `"tmux_session"` in your **first** POST so the coordinator can send you check-in broadcasts. Find your session name with `tmux display-message -p '#S'`. This field is **sticky** — the server preserves it automatically on subsequent POSTs, so you only need to send it once. -9. **Model economy.** Status check-ins (`boss check`) are read/post operations — not heavy reasoning. Use a lightweight model (e.g. Haiku) for check-ins, then switch back to your working model (e.g. Opus) for real work. The broadcast script handles this automatically via `/model` switching. +9. **Check your messages.** When you read `/raw`, look for a `#### Messages` section under your agent name. These are messages from the boss or other agents. Acknowledge them in your next status POST and act on any instructions. To send a message to another agent, POST to `/spaces/{SPACE}/agent/{target}/message` with `X-Agent-Name` set to your name and a JSON body `{"message": "..."}`. +10. **Model economy.** Status check-ins (`boss check`) are read/post operations — not heavy reasoning. Use a lightweight model (e.g. Haiku) for check-ins, then switch back to your working model (e.g. Opus) for real work. The broadcast script handles this automatically via `/model` switching. ### JSON Format Reference diff --git a/internal/coordinator/server.go b/internal/coordinator/server.go index 64d53a5..2c9ca91 100644 --- a/internal/coordinator/server.go +++ b/internal/coordinator/server.go @@ -30,20 +30,27 @@ type sseClient struct { } type Server struct { - port string - dataDir string - spaces map[string]*KnowledgeSpace - mu sync.RWMutex - httpServer *http.Server - running bool - runMu sync.Mutex - EventLog []string - eventMu sync.Mutex - stopLiveness chan struct{} - sseClients map[*sseClient]struct{} - sseMu sync.Mutex - interrupts *InterruptLedger - approvalTracked map[string]time.Time + port string + dataDir string + spaces map[string]*KnowledgeSpace + mu sync.RWMutex + httpServer *http.Server + running bool + runMu sync.Mutex + EventLog []string + eventMu sync.Mutex + stopLiveness chan struct{} + sseClients map[*sseClient]struct{} + sseMu sync.Mutex + interrupts *InterruptLedger + approvalTracked map[string]time.Time + // nudgePending tracks agents that should be nudged (check-in triggered) + // when they next become idle. Set when a message arrives for an agent. + // The liveness loop picks it up on the next tick where the agent is idle. + // Keyed by "space/agent". + nudgePending map[string]time.Time + nudgeInFlight map[string]bool // prevents duplicate concurrent nudges + nudgeMu sync.Mutex } func NewServer(port, dataDir string) *Server { @@ -58,6 +65,8 @@ func NewServer(port, dataDir string) *Server { sseClients: make(map[*sseClient]struct{}), interrupts: NewInterruptLedger(dataDir), approvalTracked: make(map[string]time.Time), + nudgePending: make(map[string]time.Time), + nudgeInFlight: make(map[string]bool), } } @@ -275,7 +284,6 @@ func (s *Server) getOrCreateSpace(name string) *KnowledgeSpace { return ks } - func (s *Server) getSpace(name string) (*KnowledgeSpace, bool) { s.mu.RLock() defer s.mu.RUnlock() @@ -702,6 +710,14 @@ func (s *Server) handleSpaceAgent(w http.ResponseWriter, r *http.Request, spaceN if update.RepoURL == "" && existing.RepoURL != "" { update.RepoURL = existing.RepoURL } + // Preserve messages — agents don't include them in updates + if len(update.Messages) == 0 && len(existing.Messages) > 0 { + update.Messages = existing.Messages + } + // Preserve documents — managed via the /agent/{name}/{slug} endpoint + if len(update.Documents) == 0 && len(existing.Documents) > 0 { + update.Documents = existing.Documents + } } ks.Agents[canonical] = &update ks.UpdatedAt = time.Now().UTC() @@ -753,7 +769,7 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac } agentName = strings.TrimRight(agentName, "/") - + // Sender authentication - require X-Agent-Name header senderName := r.Header.Get("X-Agent-Name") if senderName == "" { @@ -793,7 +809,7 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac } canonical := resolveAgentName(ks, agentName) - + s.mu.Lock() agent, exists := ks.Agents[canonical] if !exists { @@ -812,12 +828,12 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac agent.Messages = []AgentMessage{} } agent.Messages = append(agent.Messages, messageReq) - + // Limit message history to last 50 messages if len(agent.Messages) > 50 { agent.Messages = agent.Messages[len(agent.Messages)-50:] } - + ks.UpdatedAt = time.Now().UTC() if err := s.saveSpace(ks); err != nil { s.mu.Unlock() @@ -827,7 +843,7 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac s.mu.Unlock() // Log the message event - s.logEvent(fmt.Sprintf("[%s/%s] Message from %s: %s", spaceName, canonical, senderName, + s.logEvent(fmt.Sprintf("[%s/%s] Message from %s: %s", spaceName, canonical, senderName, func() string { if len(messageReq.Message) > 50 { return messageReq.Message[:47] + "..." @@ -837,17 +853,23 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac // Broadcast SSE event for real-time updates sseData, _ := json.Marshal(map[string]interface{}{ - "space": spaceName, - "agent": canonical, - "sender": senderName, + "space": spaceName, + "agent": canonical, + "sender": senderName, "message": messageReq.Message, }) s.broadcastSSE(spaceName, "agent_message", string(sseData)) + // Mark agent for nudge — the liveness loop will trigger a check-in + // when the agent is next idle, so it reads the message via /raw. + s.nudgeMu.Lock() + s.nudgePending[spaceName+"/"+canonical] = time.Now() + s.nudgeMu.Unlock() + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "delivered", + "status": "delivered", "messageId": messageReq.ID, "recipient": canonical, }) @@ -855,7 +877,7 @@ func (s *Server) handleAgentMessage(w http.ResponseWriter, r *http.Request, spac func (s *Server) handleAgentDocument(w http.ResponseWriter, r *http.Request, spaceName, agentName, documentSlug string) { agentName = strings.TrimRight(agentName, "/") - + // Agent name enforcement - ensure X-Agent-Name header matches for writes if r.Method == http.MethodPost || r.Method == http.MethodPut { callerName := r.Header.Get("X-Agent-Name") @@ -868,7 +890,7 @@ func (s *Server) handleAgentDocument(w http.ResponseWriter, r *http.Request, spa return } } - + // Sanitize document slug if !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(documentSlug) { http.Error(w, "invalid document slug: must be alphanumeric with underscores and dashes only", http.StatusBadRequest) @@ -922,18 +944,18 @@ func (s *Server) handleAgentDocument(w http.ResponseWriter, r *http.Request, spa // Update agent's documents list in the knowledge space ks := s.getOrCreateSpace(spaceName) canonical := resolveAgentName(ks, agentName) - + s.mu.Lock() if ks.Agents[canonical] == nil { ks.Agents[canonical] = &AgentUpdate{ - Status: StatusActive, - Summary: "Document uploaded", + Status: StatusActive, + Summary: "Document uploaded", UpdatedAt: time.Now().UTC(), } } - + agent := ks.Agents[canonical] - + // Add or update document in the list found := false for i, doc := range agent.Documents { @@ -950,10 +972,10 @@ func (s *Server) handleAgentDocument(w http.ResponseWriter, r *http.Request, spa Content: string(content), }) } - + agent.UpdatedAt = time.Now().UTC() ks.UpdatedAt = time.Now().UTC() - + if err := s.saveSpace(ks); err != nil { s.mu.Unlock() http.Error(w, fmt.Sprintf("save space: %v", err), http.StatusInternalServerError) @@ -1064,6 +1086,7 @@ func (s *Server) handleIgnition(w http.ResponseWriter, r *http.Request, spaceNam } else { b.WriteString("5. **Register your tmux session.** Include `\"tmux_session\"` in your first POST. Find it with `tmux display-message -p '#S'`. It is sticky — you only need to send it once.\n") } + b.WriteString(fmt.Sprintf("6. **Check your messages.** When you read `/raw`, look for a `#### Messages` section under your agent name. These are messages from the boss or other agents. Acknowledge them in your status POST and act on any instructions. To send a message to another agent: `curl -s -X POST http://localhost%s/spaces/%s/agent/{target}/message -H 'Content-Type: application/json' -H 'X-Agent-Name: %s' -d '{\"message\": \"...\"}'`\n", s.port, spaceName, agentName)) b.WriteString("\n") b.WriteString("## Peer Agents\n\n") @@ -1097,6 +1120,16 @@ func (s *Server) handleIgnition(w http.ResponseWriter, r *http.Request, spaceNam b.WriteString(fmt.Sprintf("- Next steps: %s\n", existing.NextSteps)) } b.WriteString("\n") + + if len(existing.Messages) > 0 { + b.WriteString("## Pending Messages\n\n") + b.WriteString("**You have unread messages. Read and act on them.**\n\n") + for _, msg := range existing.Messages { + b.WriteString(fmt.Sprintf("- **%s** (%s): %s\n", + msg.Sender, msg.Timestamp.Format("15:04"), msg.Message)) + } + b.WriteString("\n") + } } b.WriteString("## JSON Post Template\n\n") @@ -1575,12 +1608,52 @@ func (s *Server) checkAllSessionLiveness() { m["prompt_text"] = e.prompt } payload[i] = m + + // Check if this idle agent has a pending nudge + if e.exists && e.idle { + s.nudgeMu.Lock() + if _, pending := s.nudgePending[key]; pending { + if !s.nudgeInFlight[key] { + s.nudgeInFlight[key] = true + delete(s.nudgePending, key) + s.nudgeMu.Unlock() + go s.executeNudge(space, e.agent) + } else { + s.nudgeMu.Unlock() + } + } else { + s.nudgeMu.Unlock() + } + } } data, _ := json.Marshal(payload) s.broadcastSSE(space, "tmux_liveness", string(data)) } } +// executeNudge triggers a single-agent check-in for an agent that has +// pending messages. Called from the liveness loop when the agent is idle. +func (s *Server) executeNudge(spaceName, agentName string) { + key := spaceName + "/" + agentName + defer func() { + s.nudgeMu.Lock() + delete(s.nudgeInFlight, key) + s.nudgeMu.Unlock() + }() + + s.logEvent(fmt.Sprintf("[%s/%s] auto-nudge: message pending, triggering check-in", spaceName, agentName)) + result := s.SingleAgentCheckIn(spaceName, agentName, "", "") + + if len(result.Errors) > 0 { + s.logEvent(fmt.Sprintf("[%s/%s] auto-nudge failed: %s", spaceName, agentName, result.Errors[0])) + } else if len(result.Sent) > 0 { + s.logEvent(fmt.Sprintf("[%s/%s] auto-nudge complete", spaceName, agentName)) + } + + sseData, _ := json.Marshal(result) + s.broadcastSSE(spaceName, "broadcast_complete", string(sseData)) +} + func (s *Server) recordDecisionInterrupts(spaceName, agentName string, update *AgentUpdate) { for _, q := range update.Questions { ctx := map[string]string{} diff --git a/internal/coordinator/server_test.go b/internal/coordinator/server_test.go index 450f6f8..4515613 100644 --- a/internal/coordinator/server_test.go +++ b/internal/coordinator/server_test.go @@ -185,8 +185,8 @@ func TestRenderMarkdown(t *testing.T) { Items: []string{"CRUD for sessions", "Health check"}, }) postJSON(t, base+"/spaces/feature-123/agent/cp", AgentUpdate{ - Status: StatusBlocked, - Summary: "Waiting for API schema", + Status: StatusBlocked, + Summary: "Waiting for API schema", Blockers: []string{"Need final OpenAPI spec"}, }) @@ -797,7 +797,6 @@ func TestSSEGlobalEndpoint(t *testing.T) { } } - func TestClientDeleteAgent(t *testing.T) { srv, cleanup := mustStartServer(t) defer cleanup() @@ -1049,3 +1048,480 @@ func TestDeleteSpaceCleansUpFiles(t *testing.T) { t.Error("expected md file to be deleted") } } + +func TestLineIsIdleIndicator(t *testing.T) { + tests := []struct { + name string + line string + want bool + }{ + // Claude Code prompt (exact ">" inside box-drawing) + {"claude code prompt bare", "│ > │", true}, + {"claude code prompt no box", ">", true}, + {"claude code prompt with space", "> ", true}, + {"claude code prompt inner space", "│ > │", true}, + + // Shell prompts + {"bash dollar", "user@host:~/code$ ", true}, + {"bare dollar", "$", true}, + {"zsh percent", "% ", true}, + {"root hash", "root@box:/# ", true}, + {"fish prompt", "~/code ❯ ", true}, + {"angle bracket prompt", ">>> ", true}, + + // Claude Code prompt with auto-suggestion + {"claude code prompt bare chevron", "❯", true}, + {"claude code prompt with suggestion", "❯ give me something to work on", true}, + {"claude code prompt chevron space", "❯ ", true}, + + // Claude Code / opencode hint lines + {"shortcuts hint", "? for shortcuts", true}, + {"auto-compact", " auto-compact enabled", true}, + {"auto-accept", " auto-accept on", true}, + + // Claude Code status bar (vim mode) + {"insert mode", " -- INSERT -- ⏵⏵ bypass permissions on (shift+tab to cycle) current: 2.1.70 · latest: 2.1.70", true}, + {"normal mode", " -- NORMAL -- current: 2.1.70 · latest: 2.1.70", true}, + + // OpenCode status bar + {"opencode status bar", " ctrl+t variants tab agents ctrl+p commands • OpenCode 1.2.17", true}, + + // OpenCode / generic idle keywords + {"waiting for input", "Waiting for input...", true}, + {"ready", "Ready", true}, + {"type a message", "Type a message to begin", true}, + + // Busy indicators — should NOT match + {"running command output", "Building project...", false}, + {"file content", "func main() {", false}, + {"progress bar", "[=====> ] 50%", false}, + {"error output", "Error: file not found", false}, + {"git diff line", "+++ b/file.go", false}, + {"empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := lineIsIdleIndicator(tt.line) + if got != tt.want { + t.Errorf("lineIsIdleIndicator(%q) = %v, want %v", tt.line, got, tt.want) + } + }) + } +} + +func TestIsShellPrompt(t *testing.T) { + tests := []struct { + line string + want bool + }{ + {"$", true}, + {"$ ", true}, + {"user@host:~$ ", true}, + {"%", true}, + {"zsh% ", true}, + {">", true}, + {">>> ", true}, + {"#", true}, + {"root@box:/# ", true}, + {"~/code ❯ ", true}, + {"❯", true}, + // Not prompts + {"", false}, + {"hello world", false}, + {"func main() {", false}, + {"Building...", false}, + } + + for _, tt := range tests { + t.Run(tt.line, func(t *testing.T) { + got := isShellPrompt(tt.line) + if got != tt.want { + t.Errorf("isShellPrompt(%q) = %v, want %v", tt.line, got, tt.want) + } + }) + } +} + +// ── Message system tests ────────────────────────────────────────────── + +func postMessage(t *testing.T, baseURL, space, agent, sender, message string) *http.Response { + t.Helper() + url := baseURL + "/spaces/" + space + "/agent/" + agent + "/message" + body := `{"message":"` + message + `"}` + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Agent-Name", sender) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST message: %v", err) + } + return resp +} + +func TestMessagePostEndpoint(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // First, create an agent + postJSON(t, base+"/spaces/msg-test/agent/worker", AgentUpdate{ + Status: StatusActive, + Summary: "Working on task", + }) + + // Send a message to the agent + resp := postMessage(t, base, "msg-test", "worker", "boss", "please review the PR") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + + // Verify response contains delivery confirmation + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + if result["status"] != "delivered" { + t.Errorf("expected status=delivered, got %v", result["status"]) + } + if result["recipient"] != "Worker" { + t.Errorf("expected recipient=Worker, got %v", result["recipient"]) + } + + // Verify message is retrievable via GET agent JSON + code, body := getBody(t, base+"/spaces/msg-test/agent/worker") + if code != http.StatusOK { + t.Fatalf("expected 200, got %d", code) + } + var agent AgentUpdate + json.Unmarshal([]byte(body), &agent) + if len(agent.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(agent.Messages)) + } + if agent.Messages[0].Message != "please review the PR" { + t.Errorf("message = %q, want %q", agent.Messages[0].Message, "please review the PR") + } + if agent.Messages[0].Sender != "boss" { + t.Errorf("sender = %q, want %q", agent.Messages[0].Sender, "boss") + } + if agent.Messages[0].ID == "" { + t.Error("message ID should not be empty") + } +} + +func TestMessagePreservedOnAgentUpdate(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create agent and send a message + postJSON(t, base+"/spaces/preserve-test/agent/dev", AgentUpdate{ + Status: StatusActive, + Summary: "Working", + }) + resp := postMessage(t, base, "preserve-test", "dev", "boss", "check the logs") + resp.Body.Close() + + // Post an agent update (without messages field) + resp2 := postJSON(t, base+"/spaces/preserve-test/agent/dev", AgentUpdate{ + Status: StatusActive, + Summary: "Still working", + Items: []string{"Fixed the bug"}, + }) + resp2.Body.Close() + + // Verify message is still there + code, body := getBody(t, base+"/spaces/preserve-test/agent/dev") + if code != http.StatusOK { + t.Fatalf("expected 200, got %d", code) + } + var agent AgentUpdate + json.Unmarshal([]byte(body), &agent) + if len(agent.Messages) != 1 { + t.Fatalf("expected 1 message after update, got %d — messages were wiped", len(agent.Messages)) + } + if agent.Messages[0].Message != "check the logs" { + t.Errorf("message = %q, want %q", agent.Messages[0].Message, "check the logs") + } + // Verify the update itself was applied + if agent.Summary != "Still working" { + t.Errorf("summary = %q, want %q", agent.Summary, "Still working") + } +} + +func TestMessageRenderedInMarkdown(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create agent and send messages + postJSON(t, base+"/spaces/md-test/agent/api", AgentUpdate{ + Status: StatusActive, + Summary: "Implementing endpoints", + }) + resp := postMessage(t, base, "md-test", "api", "boss", "prioritize the health check") + resp.Body.Close() + resp = postMessage(t, base, "md-test", "api", "frontend", "I need the /users endpoint first") + resp.Body.Close() + + // GET /raw and verify messages appear in markdown + code, md := getBody(t, base+"/spaces/md-test/raw") + if code != http.StatusOK { + t.Fatalf("expected 200, got %d", code) + } + if !strings.Contains(md, "#### Messages") { + t.Error("markdown should contain '#### Messages' section") + } + if !strings.Contains(md, "prioritize the health check") { + t.Error("markdown should contain first message text") + } + if !strings.Contains(md, "I need the /users endpoint first") { + t.Error("markdown should contain second message text") + } + if !strings.Contains(md, "**boss**") { + t.Error("markdown should contain sender name 'boss'") + } + if !strings.Contains(md, "**frontend**") { + t.Error("markdown should contain sender name 'frontend'") + } +} + +func TestMessageValidation(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create agent first + postJSON(t, base+"/spaces/val-test/agent/worker", AgentUpdate{ + Status: StatusActive, + Summary: "Working", + }) + + // Test: missing X-Agent-Name header + url := base + "/spaces/val-test/agent/worker/message" + req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(`{"message":"hello"}`)) + req.Header.Set("Content-Type", "application/json") + // deliberately NOT setting X-Agent-Name + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("missing X-Agent-Name: expected 400, got %d", resp.StatusCode) + } + + // Test: empty message body + resp = postMessage(t, base, "val-test", "worker", "boss", "") + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("empty message: expected 400, got %d", resp.StatusCode) + } + + // Test: whitespace-only message + url = base + "/spaces/val-test/agent/worker/message" + req, _ = http.NewRequest(http.MethodPost, url, strings.NewReader(`{"message":" "}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Agent-Name", "boss") + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("whitespace message: expected 400, got %d", resp.StatusCode) + } + + // Test: GET method not allowed + req, _ = http.NewRequest(http.MethodGet, url, nil) + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("GET on message endpoint: expected 405, got %d", resp.StatusCode) + } +} + +func TestMessageLimit(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create agent + postJSON(t, base+"/spaces/limit-test/agent/worker", AgentUpdate{ + Status: StatusActive, + Summary: "Working", + }) + + // Send 55 messages + for i := 0; i < 55; i++ { + resp := postMessage(t, base, "limit-test", "worker", "boss", + "message number "+strings.Repeat("x", 3)+string(rune('A'+i%26))) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("message %d: expected 200, got %d", i, resp.StatusCode) + } + } + + // Verify only last 50 are retained + code, body := getBody(t, base+"/spaces/limit-test/agent/worker") + if code != http.StatusOK { + t.Fatalf("expected 200, got %d", code) + } + var agent AgentUpdate + json.Unmarshal([]byte(body), &agent) + if len(agent.Messages) != 50 { + t.Errorf("expected 50 messages (capped), got %d", len(agent.Messages)) + } +} + +func TestMessageSSEBroadcast(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create agent + postJSON(t, base+"/spaces/sse-msg-test/agent/worker", AgentUpdate{ + Status: StatusActive, + Summary: "Working", + }) + + // Connect SSE + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, "GET", base+"/spaces/sse-msg-test/events", nil) + sseResp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("SSE connect: %v", err) + } + defer sseResp.Body.Close() + + // Give SSE a moment to connect + time.Sleep(100 * time.Millisecond) + + // Send a message + resp := postMessage(t, base, "sse-msg-test", "worker", "boss", "check your inbox") + resp.Body.Close() + + // Read SSE events — look for agent_message + buf := make([]byte, 4096) + n, _ := sseResp.Body.Read(buf) + data := string(buf[:n]) + if !strings.Contains(data, "agent_message") { + t.Error("SSE should broadcast 'agent_message' event") + } + if !strings.Contains(data, "check your inbox") { + t.Error("SSE event should contain the message text") + } +} + +func TestMessageToNonexistentAgentCreatesAgent(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Send message to an agent that doesn't exist yet + resp := postMessage(t, base, "ghost-test", "phantom", "boss", "wake up") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body) + } + + // Verify agent was auto-created with the message + code, body := getBody(t, base+"/spaces/ghost-test/agent/phantom") + if code != http.StatusOK { + t.Fatalf("expected 200 for auto-created agent, got %d", code) + } + var agent AgentUpdate + json.Unmarshal([]byte(body), &agent) + if agent.Status != StatusIdle { + t.Errorf("auto-created agent status = %q, want %q", agent.Status, StatusIdle) + } + if len(agent.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(agent.Messages)) + } + if agent.Messages[0].Message != "wake up" { + t.Errorf("message = %q, want %q", agent.Messages[0].Message, "wake up") + } +} + +func TestIgnitionEndpoint(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create a peer agent so ignition shows it + postJSON(t, base+"/spaces/ignite-test/agent/peer", AgentUpdate{ + Status: StatusActive, + Summary: "Peer is working", + }) + + // GET ignition for a new agent + code, body := getBody(t, base+"/spaces/ignite-test/ignition/newagent?tmux_session=test_session_123") + if code != http.StatusOK { + t.Fatalf("expected 200, got %d", code) + } + + // Verify key sections exist + if !strings.Contains(body, "# Agent Ignition: newagent") { + t.Error("missing ignition title") + } + if !strings.Contains(body, "You are **newagent**") { + t.Error("missing agent identity") + } + if !strings.Contains(body, "test_session_123") { + t.Error("missing tmux session in response") + } + if !strings.Contains(body, "Peer") { + t.Error("missing peer agent in response") + } + if !strings.Contains(body, "/message") { + t.Error("ignition should document the /message endpoint") + } + + // Verify tmux session was registered + agentCode, agentBody := getBody(t, base+"/spaces/ignite-test/agent/newagent") + if agentCode != http.StatusOK { + t.Fatalf("expected 200 for registered agent, got %d", agentCode) + } + var agent AgentUpdate + json.Unmarshal([]byte(agentBody), &agent) + if agent.TmuxSession != "test_session_123" { + t.Errorf("tmux_session = %q, want %q", agent.TmuxSession, "test_session_123") + } +} + +func TestIgnitionShowsPendingMessages(t *testing.T) { + srv, cleanup := mustStartServer(t) + defer cleanup() + base := serverBaseURL(srv) + + // Create agent and send a message + postJSON(t, base+"/spaces/ignite-msg-test/agent/worker", AgentUpdate{ + Status: StatusIdle, + Summary: "Idle", + }) + resp := postMessage(t, base, "ignite-msg-test", "worker", "boss", "start working on feature X") + resp.Body.Close() + + // GET ignition — should show pending messages + code, body := getBody(t, base+"/spaces/ignite-msg-test/ignition/worker") + if code != http.StatusOK { + t.Fatalf("expected 200, got %d", code) + } + if !strings.Contains(body, "Pending Messages") { + t.Error("ignition should show 'Pending Messages' section") + } + if !strings.Contains(body, "start working on feature X") { + t.Error("ignition should show the pending message text") + } + if !strings.Contains(body, "**boss**") { + t.Error("ignition should show the message sender") + } +} diff --git a/internal/coordinator/tmux.go b/internal/coordinator/tmux.go index 531df5a..6024432 100644 --- a/internal/coordinator/tmux.go +++ b/internal/coordinator/tmux.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" ) const ( @@ -224,23 +225,128 @@ func tmuxApprove(session string) error { return exec.CommandContext(ctx, "tmux", "send-keys", "-t", session, "Enter").Run() } +// tmuxIsIdle reports whether the tmux session appears to be waiting for input +// (i.e., no agent or process is actively running). It is intentionally generous: +// a session is "busy" only when there is positive evidence of activity. func tmuxIsIdle(session string) bool { - lines, err := tmuxCapturePaneLines(session, 5) + lines, err := tmuxCapturePaneLines(session, 10) if err != nil { - return false + // Cannot read the pane — default to idle rather than falsely reporting busy. + return true + } + + // An entirely empty pane (all blank lines) is idle. + if len(lines) == 0 { + return true } + + // Check each of the last N non-empty lines for idle indicators. for _, line := range lines { - inner := strings.TrimSpace(strings.ReplaceAll(line, "│", "")) - if inner == ">" { + if lineIsIdleIndicator(line) { return true } - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "?") && strings.Contains(trimmed, "for shortcuts") { - return true + } + return false +} + +// lineIsIdleIndicator returns true if a single pane line indicates the session +// is idle / waiting for user input. +func lineIsIdleIndicator(line string) bool { + trimmed := strings.TrimSpace(line) + // Strip box-drawing characters used by Claude Code / opencode TUI. + // Both light (│ U+2502) and heavy (┃ U+2503) verticals are used. + inner := trimmed + inner = strings.ReplaceAll(inner, "│", "") + inner = strings.ReplaceAll(inner, "┃", "") + inner = strings.TrimSpace(inner) + + // ── Claude Code / opencode prompt ── + // The prompt line inside the TUI box is just ">" (possibly with trailing space). + if inner == ">" || inner == "> " { + return true + } + + // ── Claude Code prompt with suggestion ── + // Claude Code shows "❯" as its prompt. When idle it may auto-fill a + // suggested prompt after the ❯ (e.g. "❯ give me something to work on"). + // A line starting with ❯ means the agent is waiting for input regardless + // of what follows (user-typed text or auto-suggestion). + if strings.HasPrefix(trimmed, "❯") { + return true + } + + // ── Shell prompts ── + // Common interactive shell prompts end with $, %, >, #, or ❯ possibly + // followed by a space. We check the last non-space rune of the line. + if isShellPrompt(trimmed) { + return true + } + + // ── Claude Code / opencode hint lines ── + if strings.HasPrefix(trimmed, "?") && strings.Contains(trimmed, "for shortcuts") { + return true + } + if strings.Contains(trimmed, "auto-compact") || strings.Contains(trimmed, "auto-accept") { + return true + } + + // ── Claude Code / opencode status bar ── + // OpenCode's bottom bar contains "ctrl+p commands" when idle. + // Claude Code's bottom bar contains "-- INSERT --" or "-- NORMAL --" (vim mode). + if strings.Contains(trimmed, "ctrl+p commands") { + return true + } + if strings.Contains(trimmed, "-- INSERT --") || strings.Contains(trimmed, "-- NORMAL --") { + return true + } + + // ── OpenCode / Claude Code status bar keywords ── + lower := strings.ToLower(trimmed) + if strings.Contains(lower, "waiting for input") || + strings.Contains(lower, "ready") || + strings.Contains(lower, "type a message") || + strings.Contains(lower, "press enter") { + return true + } + + return false +} + +// isShellPrompt returns true if the line looks like a common shell prompt. +// It matches lines whose last meaningful character is one of $, %, >, #, or ❯, +// but guards against false positives like "50%" or "line #3". +func isShellPrompt(line string) bool { + s := strings.TrimRight(line, " \t") + if s == "" { + return false + } + last, size := utf8.DecodeLastRuneInString(s) + switch last { + case '$', '❯', '»': + // These are unambiguous prompt characters. + return true + case '>': + // Reject "=>" (fat arrow), "->" (arrow), but allow bare ">" or ">>> ". + if len(s) >= 2 { + prev := s[len(s)-2] + if prev == '=' || prev == '-' { + return false + } } - if strings.Contains(trimmed, "auto-compact") || strings.Contains(trimmed, "auto-accept") { - return true + return true + case '%', '#': + // Reject "50%" or "line #3" — these chars are only prompts when NOT + // preceded by a digit. + before := s[:len(s)-size] + before = strings.TrimRight(before, " \t") + if before == "" { + return true // bare "%" or "#" + } + prevChar := before[len(before)-1] + if prevChar >= '0' && prevChar <= '9' { + return false } + return true } return false } @@ -336,22 +442,26 @@ func (s *Server) runAgentCheckIn(spaceName, canonical, tmuxSession, checkModel, s.broadcastProgress(spaceName, canonical+": "+msg) } - progress("switching to " + checkModel) - if err := tmuxSendKeys(tmuxSession, "/model "+checkModel); err != nil { - result.addError(canonical + ": model switch failed: " + err.Error()) - return - } + // Model economy: switch to a lightweight model for check-ins if configured. + // If checkModel is empty, skip model switching entirely. + if checkModel != "" { + progress("switching to " + checkModel) + if err := tmuxSendKeys(tmuxSession, "/model "+checkModel); err != nil { + result.addError(canonical + ": model switch failed: " + err.Error()) + return + } - progress("waiting for model switch...") - if err := waitForIdle(tmuxSession, idlePollTimeout); err != nil { - result.addError(canonical + ": model switch did not complete: " + err.Error()) - return + progress("waiting for model switch...") + if err := waitForIdle(tmuxSession, idlePollTimeout); err != nil { + result.addError(canonical + ": model switch did not complete: " + err.Error()) + return + } } boardTimeBefore := s.agentUpdatedAt(spaceName, canonical) - progress("sending /boss-check prompt") - if err := tmuxSendKeys(tmuxSession, "/boss-check "+canonical+" "+spaceName); err != nil { + progress("sending /boss.check prompt") + if err := tmuxSendKeys(tmuxSession, "/boss.check "+canonical+" "+spaceName); err != nil { result.addError(canonical + ": check-in send failed: " + err.Error()) return } @@ -364,20 +474,23 @@ func (s *Server) runAgentCheckIn(spaceName, canonical, tmuxSession, checkModel, result.addSent(canonical) progress("board post received") - progress("waiting for idle before model restore...") - if err := waitForIdle(tmuxSession, idlePollTimeout); err != nil { - result.addError(canonical + ": post-checkin idle wait failed: " + err.Error()) - } + // Restore the working model if one was specified + if workModel != "" { + progress("waiting for idle before model restore...") + if err := waitForIdle(tmuxSession, idlePollTimeout); err != nil { + result.addError(canonical + ": post-checkin idle wait failed: " + err.Error()) + } - progress("restoring " + workModel) - if err := tmuxSendKeys(tmuxSession, "/model "+workModel); err != nil { - result.addError(canonical + ": model restore failed: " + err.Error()) - return - } + progress("restoring " + workModel) + if err := tmuxSendKeys(tmuxSession, "/model "+workModel); err != nil { + result.addError(canonical + ": model restore failed: " + err.Error()) + return + } - progress("waiting for model restore...") - if err := waitForIdle(tmuxSession, idlePollTimeout); err != nil { - result.addError(canonical + ": model restore did not complete: " + err.Error()) + progress("waiting for model restore...") + if err := waitForIdle(tmuxSession, idlePollTimeout); err != nil { + result.addError(canonical + ": model restore did not complete: " + err.Error()) + } } progress("complete") diff --git a/internal/coordinator/types.go b/internal/coordinator/types.go index 30e64a2..0fbd822 100644 --- a/internal/coordinator/types.go +++ b/internal/coordinator/types.go @@ -42,24 +42,24 @@ func (s AgentStatus) Emoji() string { } type AgentUpdate struct { - Status AgentStatus `json:"status"` - Summary string `json:"summary"` - Branch string `json:"branch,omitempty"` - Worktree string `json:"worktree,omitempty"` - PR string `json:"pr,omitempty"` - Phase string `json:"phase,omitempty"` - TestCount *int `json:"test_count,omitempty"` - Items []string `json:"items,omitempty"` - Sections []Section `json:"sections,omitempty"` - Questions []string `json:"questions,omitempty"` - Blockers []string `json:"blockers,omitempty"` - NextSteps string `json:"next_steps,omitempty"` - FreeText string `json:"free_text,omitempty"` - Documents []AgentDocument `json:"documents,omitempty"` - TmuxSession string `json:"tmux_session,omitempty"` - RepoURL string `json:"repo_url,omitempty"` - Messages []AgentMessage `json:"messages,omitempty"` - UpdatedAt time.Time `json:"updated_at"` + Status AgentStatus `json:"status"` + Summary string `json:"summary"` + Branch string `json:"branch,omitempty"` + Worktree string `json:"worktree,omitempty"` + PR string `json:"pr,omitempty"` + Phase string `json:"phase,omitempty"` + TestCount *int `json:"test_count,omitempty"` + Items []string `json:"items,omitempty"` + Sections []Section `json:"sections,omitempty"` + Questions []string `json:"questions,omitempty"` + Blockers []string `json:"blockers,omitempty"` + NextSteps string `json:"next_steps,omitempty"` + FreeText string `json:"free_text,omitempty"` + Documents []AgentDocument `json:"documents,omitempty"` + TmuxSession string `json:"tmux_session,omitempty"` + RepoURL string `json:"repo_url,omitempty"` + Messages []AgentMessage `json:"messages,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } type Section struct { @@ -226,6 +226,15 @@ func renderAgentSection(name string, agent *AgentUpdate) string { b.WriteString("\n\n") } + if len(agent.Messages) > 0 { + b.WriteString("#### Messages\n\n") + for _, msg := range agent.Messages { + b.WriteString(fmt.Sprintf("- **%s** (%s): %s\n", + msg.Sender, msg.Timestamp.Format("15:04"), msg.Message)) + } + b.WriteString("\n") + } + if len(agent.Documents) > 0 { b.WriteString("#### Documents\n\n") for _, doc := range agent.Documents {