sdaduanbilei fe1a82bbe2 feat: Plan/Build 模式、artifact 系统、成员直接对话
- 新增 Plan/Build 模式切换(Tab 键),Plan 模式阻止任务执行
- Build 模式:成员输出智能判断,文档存 artifact,提问显示聊天
- 成员可直接与用户对话(多轮),不经过 master 传话
- 任务计划文档自动生成,沟通记录自动追加
- 右侧面板重构为产出物面板,支持查看/编辑/保存
- 输入框改为 textarea,支持 Shift+Enter 换行,修复输入法 Enter 误发送
- Master 会话历史持久化,支持多轮上下文
- parseAssignments 支持多行任务描述
- SOUL.md 热重载、skill 递归发现与内容注入
- 需求确认 skill(HARD-GATE 模式)
- air 热重载配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:34:44 +08:00

738 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package room
import (
"bytes"
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/sdaduanbilei/agent-team/internal/agent"
"github.com/sdaduanbilei/agent-team/internal/llm"
"github.com/sdaduanbilei/agent-team/internal/skill"
"github.com/sdaduanbilei/agent-team/internal/user"
"gopkg.in/yaml.v3"
)
type RoomType string
const (
TypeProject RoomType = "project"
)
type Status string
const (
StatusPending Status = "pending"
StatusThinking Status = "thinking"
StatusWorking Status = "working"
)
type Config struct {
Name string `yaml:"name"`
Type RoomType `yaml:"type"`
Master string `yaml:"master"` // agent name
Members []string `yaml:"members"` // agent names
Color string `yaml:"color"` // avatar color
Team string `yaml:"team"` // installed team name
}
type Room struct {
Config Config
Dir string
master *agent.Agent
members map[string]*agent.Agent
skillMeta []skill.Meta
User *user.User
Status Status
ActiveAgent string // for working status display
Broadcast func(Event) // set by api layer
// master 的会话历史,保持多轮对话上下文
masterHistory []llm.Message
historyMu sync.Mutex
Mode string // "plan" | "build"
pendingAssignments map[string]string // plan 模式下暂存的待执行任务
pendingPlanReply string // master 规划原文,用于生成计划文档
// Build 模式下成员对话跟踪
memberConvos map[string][]llm.Message // 成员名 -> 多轮对话历史
lastActiveMember string // 最后一个发出提问的成员
planFilename string // 当前任务计划文件名
}
type EventType string
const (
EvtAgentMessage EventType = "agent_message"
EvtTaskAssign EventType = "task_assign"
EvtReview EventType = "review"
EvtRoomStatus EventType = "room_status"
EvtTasksUpdate EventType = "tasks_update"
EvtWorkspaceFile EventType = "workspace_file"
EvtModeChange EventType = "mode_change"
EvtArtifact EventType = "artifact"
)
type Event struct {
Type EventType `json:"type"`
RoomID string `json:"room_id"`
Agent string `json:"agent,omitempty"`
Role string `json:"role,omitempty"` // master | member | challenge
Content string `json:"content,omitempty"`
Streaming bool `json:"streaming,omitempty"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Task string `json:"task,omitempty"`
Feedback string `json:"feedback,omitempty"`
Status Status `json:"status,omitempty"`
ActiveAgent string `json:"active_agent,omitempty"`
Action string `json:"action,omitempty"`
Filename string `json:"filename,omitempty"`
Mode string `json:"mode,omitempty"`
Title string `json:"title,omitempty"`
}
type BoardEntry struct {
Author string
Content string
Type string // "draft" | "challenge"
}
type SharedBoard struct {
mu sync.RWMutex
entries []BoardEntry
}
func (b *SharedBoard) Add(author, content, typ string) {
b.mu.Lock()
defer b.mu.Unlock()
b.entries = append(b.entries, BoardEntry{Author: author, Content: content, Type: typ})
}
func (b *SharedBoard) ToContext() string {
b.mu.RLock()
defer b.mu.RUnlock()
if len(b.entries) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("<team_board>\n")
for _, e := range b.entries {
fmt.Fprintf(&sb, " <entry type=\"%s\" author=\"%s\">\n%s\n </entry>\n", e.Type, e.Author, e.Content)
}
sb.WriteString("</team_board>")
return sb.String()
}
func Load(roomDir string, agentsDir string, skillsDir string) (*Room, error) {
data, err := os.ReadFile(filepath.Join(roomDir, "room.md"))
if err != nil {
return nil, err
}
cfg, err := parseRoomConfig(data)
if err != nil {
return nil, err
}
r := &Room{Config: cfg, Dir: roomDir, members: make(map[string]*agent.Agent), Mode: "plan"}
if cfg.Master != "" {
agentPath := resolveAgentPath(agentsDir, cfg.Team, cfg.Master)
r.master, err = agent.Load(agentPath)
if err != nil {
return nil, fmt.Errorf("load master %s: %w", cfg.Master, err)
}
for _, name := range cfg.Members {
a, err := agent.Load(resolveAgentPath(agentsDir, cfg.Team, name))
if err != nil {
return nil, fmt.Errorf("load member %s: %w", name, err)
}
r.members[name] = a
}
}
r.skillMeta, _ = skill.Discover(skillsDir)
return r, nil
}
func (r *Room) emit(e Event) {
e.RoomID = r.Config.Name
if r.Broadcast != nil {
r.Broadcast(e)
}
}
func (r *Room) setMode(mode string) {
r.Mode = mode
r.emit(Event{Type: EvtModeChange, Mode: mode})
}
func (r *Room) setStatus(s Status, activeAgent, action string) {
r.Status = s
r.ActiveAgent = activeAgent
r.emit(Event{Type: EvtRoomStatus, Status: s, ActiveAgent: activeAgent, Action: action})
}
// AppendHistory persists a message to today's history file.
func (r *Room) AppendHistory(role, agentName, content string) {
dir := filepath.Join(r.Dir, "history")
os.MkdirAll(dir, 0755)
filename := filepath.Join(dir, time.Now().Format("2006-01-02")+".md")
line := fmt.Sprintf("\n**[%s] %s** (%s)\n\n%s\n", time.Now().Format("15:04:05"), agentName, role, content)
f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
f.WriteString(line)
}
func (r *Room) runMembersParallel(ctx context.Context, assignments map[string]string, board *SharedBoard, skillXML string) map[string]string {
results := make(map[string]string)
var mu sync.Mutex
var wg sync.WaitGroup
for memberName, task := range assignments {
wg.Add(1)
go func(name, t string) {
defer wg.Done()
member, ok := r.members[name]
if !ok {
return
}
r.setStatus(StatusWorking, member.Config.Name, t)
r.emit(Event{Type: EvtTaskAssign, From: r.master.Config.Name, To: name, Task: t})
// Build 模式:发送简要状态消息,不流式输出内容
taskBrief := t
if len(taskBrief) > 60 {
taskBrief = taskBrief[:60] + "..."
}
r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "member", Content: fmt.Sprintf("正在处理: %s", taskBrief)})
boardCtx := board.ToContext()
extraCtx := skillXML
if boardCtx != "" {
extraCtx = boardCtx + "\n\n" + skillXML
}
memberSystem := member.BuildSystemPrompt(extraCtx)
memberMsgs := []llm.Message{
llm.NewMsg("system", memberSystem),
llm.NewMsg("user", t),
}
// 静默收集输出,不流式推送到聊天
var memberReply strings.Builder
_, err := member.Chat(ctx, memberMsgs, func(token string) {
memberReply.WriteString(token)
})
if err != nil {
mu.Lock()
results[name] = fmt.Sprintf("[error] %v", err)
mu.Unlock()
return
}
result := memberReply.String()
mu.Lock()
results[name] = result
mu.Unlock()
r.AppendHistory("member", name, result)
board.Add(name, result, "draft")
// 保存成员对话历史,支持后续多轮交互
if r.memberConvos == nil {
r.memberConvos = make(map[string][]llm.Message)
}
r.memberConvos[name] = memberMsgs
r.memberConvos[name] = append(r.memberConvos[name], llm.NewMsg("assistant", result))
// 智能判断:文档 → artifact对话/提问 → 聊天消息
if isDocument(result) {
title := extractTitle(result)
filename := fmt.Sprintf("%s-%s.md", name, time.Now().Format("20060102-150405"))
r.saveWorkspace(filename, result)
r.emit(Event{Type: EvtArtifact, Agent: name, Filename: filename, Title: title})
} else {
// 非文档内容(提问、简短回复等)直接显示在聊天中
r.emit(Event{Type: EvtAgentMessage, Agent: name, Role: "member", Content: result})
r.lastActiveMember = name
}
}(memberName, task)
}
wg.Wait()
return results
}
func (r *Room) runChallengeRound(ctx context.Context, board *SharedBoard, skillXML string) {
var challengers []string
for name, member := range r.members {
if member.Config.CanChallenge {
challengers = append(challengers, name)
}
}
if len(challengers) == 0 {
return
}
boardCtx := board.ToContext()
if boardCtx == "" {
return
}
var wg sync.WaitGroup
for _, name := range challengers {
wg.Add(1)
go func(n string) {
defer wg.Done()
member := r.members[n]
extraCtx := boardCtx + "\n\n" + skillXML
memberSystem := member.BuildSystemPrompt(extraCtx)
memberMsgs := []llm.Message{
llm.NewMsg("system", memberSystem+"\n\nReview the team board above. If you see issues or want to challenge any draft, output CHALLENGE:<your concern>. Otherwise output AGREE."),
llm.NewMsg("user", "Please review the team board and provide your feedback."),
}
var reply strings.Builder
_, err := member.Chat(ctx, memberMsgs, func(token string) {
reply.WriteString(token)
r.emit(Event{Type: EvtAgentMessage, Agent: n, Role: "challenge", Content: token, Streaming: true})
})
if err != nil {
return
}
r.emit(Event{Type: EvtAgentMessage, Agent: n, Role: "challenge", Content: "", Streaming: false})
result := reply.String()
if strings.Contains(result, "CHALLENGE:") {
board.Add(n, result, "challenge")
r.AppendHistory("challenge", n, result)
}
}(name)
}
wg.Wait()
}
// Handle processes a user message through master orchestration.
func (r *Room) Handle(ctx context.Context, userMsg string) error {
return r.HandleUserMessage(ctx, "user", userMsg)
}
// parseUserMentions 从用户消息中提取 @agent 指派。
// 返回指派 map 和去除 @agent 后的剩余消息。
func parseUserMentions(text string, validMembers map[string]*agent.Agent) map[string]string {
assignments := make(map[string]string)
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "@") {
continue
}
rest := strings.TrimPrefix(line, "@")
idx := strings.IndexAny(rest, " \t")
if idx <= 0 {
// 只有 @name 没有任务内容,跳过
continue
}
name := strings.TrimSpace(rest[:idx])
task := strings.TrimSpace(rest[idx+1:])
if _, ok := validMembers[name]; ok && task != "" {
assignments[name] = task
}
}
return assignments
}
// HandleUserMessage 处理用户消息。
// 如果用户消息中包含 @agent直接将任务分配给对应 agent不经过 master。
func (r *Room) HandleUserMessage(ctx context.Context, userName, userMsg string) error {
if r.master == nil {
return fmt.Errorf("room has no master agent configured")
}
r.AppendHistory("user", userName, userMsg)
// 检测用户是否直接 @agent 指派任务
userAssignments := parseUserMentions(userMsg, r.members)
if len(userAssignments) > 0 {
return r.handleDirectAssign(ctx, userAssignments)
}
// Build 模式下,如果有 plan 阶段暂存的待执行任务,直接执行
if r.Mode == "build" && len(r.pendingAssignments) > 0 {
log.Printf("[room %s] build 模式,执行 %d 个暂存任务", r.Config.Name, len(r.pendingAssignments))
assignments := r.pendingAssignments
r.pendingAssignments = nil
// 生成任务计划文档
r.planFilename = fmt.Sprintf("任务计划-%s.md", time.Now().Format("20060102-150405"))
planContent := "# 任务计划\n\n## 规划\n\n" + r.pendingPlanReply + "\n"
r.pendingPlanReply = ""
r.saveWorkspace(r.planFilename, planContent)
r.emit(Event{Type: EvtArtifact, Agent: r.master.Config.Name, Filename: r.planFilename, Title: "任务计划"})
skillXML := skill.ToXML(r.skillMeta)
board := &SharedBoard{}
r.setStatus(StatusWorking, "", "")
r.runMembersParallel(ctx, assignments, board, skillXML)
r.runChallengeRound(ctx, board, skillXML)
r.setStatus(StatusPending, "", "")
return nil
}
// Build 模式下,成员发出了提问,用户回复 → 转发给该成员继续对话
if r.Mode == "build" && r.lastActiveMember != "" {
memberName := r.lastActiveMember
member, ok := r.members[memberName]
if !ok {
return fmt.Errorf("member %s not found", memberName)
}
log.Printf("[room %s] build 模式,将用户回复转发给 %s", r.Config.Name, memberName)
r.setStatus(StatusWorking, member.Config.Name, "")
// 追加用户回复到成员对话历史
if r.memberConvos == nil {
r.memberConvos = make(map[string][]llm.Message)
}
r.memberConvos[memberName] = append(r.memberConvos[memberName], llm.NewMsg("user", userMsg))
// 追加沟通记录到任务计划文档
r.appendPlanLog(userName, memberName, userMsg)
// 让成员继续对话
var memberReply strings.Builder
_, err := member.Chat(ctx, r.memberConvos[memberName], func(token string) {
memberReply.WriteString(token)
})
if err != nil {
r.setStatus(StatusPending, "", "")
return err
}
result := memberReply.String()
r.memberConvos[memberName] = append(r.memberConvos[memberName], llm.NewMsg("assistant", result))
r.AppendHistory("member", memberName, result)
// 追加成员回复到任务计划文档
r.appendPlanLog(memberName, userName, result)
// 智能判断输出类型
if isDocument(result) {
title := extractTitle(result)
filename := fmt.Sprintf("%s-%s.md", memberName, time.Now().Format("20060102-150405"))
r.saveWorkspace(filename, result)
r.emit(Event{Type: EvtArtifact, Agent: memberName, Filename: filename, Title: title})
r.lastActiveMember = "" // 文档产出,对话结束
} else {
r.emit(Event{Type: EvtAgentMessage, Agent: memberName, Role: "member", Content: result})
// lastActiveMember 保持不变,用户可以继续回复
}
r.setStatus(StatusPending, "", "")
return nil
}
r.setStatus(StatusThinking, "", "")
// 构建 system prompt
teamXML := r.buildTeamXML()
skillXML := skill.ToXML(r.skillMeta)
var userXML string
if r.User != nil {
userXML = r.User.BuildUserXML()
}
extraContext := userXML + "\n\n" + teamXML + "\n\n" + skillXML
systemPrompt := r.master.BuildSystemPrompt(extraContext)
sysMsg := llm.NewMsg("system", systemPrompt+fmt.Sprintf(`
当前用户:%s
分配任务给成员时,使用 @ 格式,每行一个:
@成员名 任务描述
直接回复用户时,正常说话即可,不需要 @。`, userName))
// 使用持久化的会话历史
r.historyMu.Lock()
// 始终更新 system prompt可能 SOUL.md 改了)
if len(r.masterHistory) == 0 {
r.masterHistory = []llm.Message{sysMsg}
} else {
r.masterHistory[0] = sysMsg
}
r.masterHistory = append(r.masterHistory, llm.NewMsg("user", userMsg))
// 限制历史长度,保留 system + 最近 20 轮对话
if len(r.masterHistory) > 41 {
r.masterHistory = append(r.masterHistory[:1], r.masterHistory[len(r.masterHistory)-40:]...)
}
masterMsgs := make([]llm.Message, len(r.masterHistory))
copy(masterMsgs, r.masterHistory)
r.historyMu.Unlock()
// Master 规划循环
for iteration := 0; iteration < 5; iteration++ {
log.Printf("[room %s] master iteration %d, sending to LLM...", r.Config.Name, iteration)
var masterReply strings.Builder
_, err := r.master.Chat(ctx, masterMsgs, func(token string) {
masterReply.WriteString(token)
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: token, Streaming: true})
})
if err != nil {
log.Printf("[room %s] master chat error: %v", r.Config.Name, err)
return err
}
reply := masterReply.String()
log.Printf("[room %s] master reply (%d chars): %.100s...", r.Config.Name, len(reply), reply)
// 发送 streaming 结束信号
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master", Content: "", Streaming: false})
assistantMsg := llm.NewMsg("assistant", reply)
masterMsgs = append(masterMsgs, assistantMsg)
// 同步到持久化历史
r.historyMu.Lock()
r.masterHistory = append(r.masterHistory, assistantMsg)
r.historyMu.Unlock()
r.AppendHistory("master", r.master.Config.Name, reply)
// 解析 @ 指令,只匹配已知成员名
allMentions := parseAssignments(reply)
assignments := make(map[string]string)
for name, task := range allMentions {
if _, isMember := r.members[name]; isMember {
assignments[name] = task
}
}
if len(assignments) == 0 {
// 没有分配给任何成员的任务master 直接回复了用户
break
}
// Plan 模式下不执行任务,暂存任务,提示用户切换到 Build 模式
if r.Mode != "build" {
r.pendingAssignments = assignments
r.pendingPlanReply = reply
log.Printf("[room %s] plan 模式,暂存 %d 个任务,等待用户切换到 build 模式", r.Config.Name, len(assignments))
r.emit(Event{Type: EvtAgentMessage, Agent: r.master.Config.Name, Role: "master",
Content: "已完成任务规划。请按 Tab 切换到 Build 模式,然后发送确认开始执行。"})
break
}
// 并行执行成员任务
board := &SharedBoard{}
results := r.runMembersParallel(ctx, assignments, board, skillXML)
// 质疑轮
r.runChallengeRound(ctx, board, skillXML)
// 将结果反馈给 master 审查
r.setStatus(StatusThinking, "", "")
var resultsStr strings.Builder
for memberName, result := range results {
resultsStr.WriteString(fmt.Sprintf("[%s] %s\n", memberName, result))
}
boardCtx := board.ToContext()
feedbackMsg := "Team results:\n" + resultsStr.String()
if boardCtx != "" {
feedbackMsg += "\n\nTeam board:\n" + boardCtx
}
feedbackMsg += "\n\n请审查以上结果。如果满意直接回复总结给用户即可。如果需要修改使用 @成员名 分配修订任务。"
feedbackLLMMsg := llm.NewMsg("user", feedbackMsg)
masterMsgs = append(masterMsgs, feedbackLLMMsg)
// 同步反馈消息到持久化历史
r.historyMu.Lock()
r.masterHistory = append(r.masterHistory, feedbackLLMMsg)
r.historyMu.Unlock()
// 更新任务列表
r.updateTasks(masterMsgs)
}
r.setStatus(StatusPending, "", "")
// Auto-update master memory after task completion
go r.updateMasterMemory(context.Background(), userMsg, masterMsgs)
return nil
}
// handleDirectAssign 处理用户直接 @agent 指派的任务,跳过 master 规划。
func (r *Room) handleDirectAssign(ctx context.Context, assignments map[string]string) error {
skillXML := skill.ToXML(r.skillMeta)
board := &SharedBoard{}
r.runMembersParallel(ctx, assignments, board, skillXML)
r.runChallengeRound(ctx, board, skillXML)
r.setStatus(StatusPending, "", "")
return nil
}
func (r *Room) updateMasterMemory(ctx context.Context, task string, msgs []llm.Message) {
summaryPrompt := fmt.Sprintf("Based on this task: %q\nSummarize key learnings and patterns in 3-5 bullet points for future reference. Be concise.", task)
memMsgs := append(msgs, llm.NewMsg("user", summaryPrompt))
summary, err := r.master.Chat(ctx, memMsgs, nil)
if err != nil || summary == "" {
return
}
filename := time.Now().Format("2006-01") + ".md"
existing, _ := os.ReadFile(filepath.Join(r.master.Dir, "memory", filename))
content := string(existing) + fmt.Sprintf("\n## %s — %s\n\n%s\n", time.Now().Format("2006-01-02"), task[:min(50, len(task))], summary)
r.master.SaveMemory(filename, content)
}
// isDocument 判断内容是否为文档产出物(而非对话/提问)。
// 文档特征:包含 markdown 标题且内容较长。
func isDocument(content string) bool {
hasHeading := strings.Contains(content, "\n# ") || strings.HasPrefix(content, "# ")
return hasHeading && len([]rune(content)) > 500
}
func extractTitle(content string) string {
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "# ") {
return strings.TrimPrefix(line, "# ")
}
}
return ""
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// parseAssignments 解析任务分配指令。
// 支持多行任务描述:从 @成员名 开始,到下一个 @成员名 或文本结束为止。
func parseAssignments(text string) map[string]string {
result := make(map[string]string)
lines := strings.Split(text, "\n")
var currentName string
var currentTask strings.Builder
flush := func() {
if currentName != "" {
task := strings.TrimSpace(currentTask.String())
if task != "" {
result[currentName] = task
}
}
currentName = ""
currentTask.Reset()
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// 检测新的 @成员名 行
if strings.HasPrefix(trimmed, "@") {
rest := strings.TrimPrefix(trimmed, "@")
if idx := strings.IndexAny(rest, " \t"); idx > 0 {
name := strings.TrimSpace(rest[:idx])
task := strings.TrimSpace(rest[idx+1:])
// 保存前一个
flush()
currentName = name
if task != "" {
currentTask.WriteString(task)
}
continue
}
}
// ASSIGN:成员名:任务描述(向后兼容)
if strings.HasPrefix(trimmed, "ASSIGN:") {
parts := strings.SplitN(strings.TrimPrefix(trimmed, "ASSIGN:"), ":", 2)
if len(parts) == 2 {
flush()
result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
continue
}
// 当前有 @成员名 在收集中,追加后续行作为任务描述
if currentName != "" && trimmed != "" {
if currentTask.Len() > 0 {
currentTask.WriteString("\n")
}
currentTask.WriteString(trimmed)
}
}
flush()
return result
}
func (r *Room) buildTeamXML() string {
var sb strings.Builder
sb.WriteString("<team_members>\n")
for name, a := range r.members {
fmt.Fprintf(&sb, " <member>\n <name>%s</name>\n <description>%s</description>\n </member>\n", name, a.Config.Description)
}
sb.WriteString("</team_members>")
return sb.String()
}
// appendPlanLog 将沟通记录追加到任务计划文档
func (r *Room) appendPlanLog(from, to, content string) {
if r.planFilename == "" {
return
}
path := filepath.Join(r.Dir, "workspace", r.planFilename)
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
entry := fmt.Sprintf("\n---\n**[%s] %s → %s**\n\n%s\n",
time.Now().Format("15:04:05"), from, to, content)
f.WriteString(entry)
}
func (r *Room) saveWorkspace(filename, content string) {
dir := filepath.Join(r.Dir, "workspace")
os.MkdirAll(dir, 0755)
os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644)
}
func (r *Room) updateTasks(msgs []llm.Message) {
// 从对话中提取任务列表并保存
var tasks strings.Builder
tasks.WriteString("# Tasks\n\n")
for _, m := range msgs {
if m.Role == "assistant" {
assignments := parseAssignments(m.Content)
for name, task := range assignments {
tasks.WriteString(fmt.Sprintf("- [ ] [%s] %s\n", name, task))
}
}
}
content := tasks.String()
os.WriteFile(filepath.Join(r.Dir, "tasks.md"), []byte(content), 0644)
r.emit(Event{Type: EvtTasksUpdate, Content: content})
}
func parseRoomConfig(data []byte) (Config, error) {
var cfg Config
if !bytes.HasPrefix(data, []byte("---")) {
return cfg, fmt.Errorf("missing frontmatter")
}
parts := bytes.SplitN(data, []byte("---"), 3)
if len(parts) < 3 {
return cfg, fmt.Errorf("invalid frontmatter")
}
return cfg, yaml.Unmarshal(parts[1], &cfg)
}
// resolveAgentPath finds agent dir: prefers agentsDir/team/name, falls back to agentsDir/name
func resolveAgentPath(agentsDir, team, name string) string {
if team != "" {
p := filepath.Join(agentsDir, team, name)
if _, err := os.Stat(p); err == nil {
return p
}
}
return filepath.Join(agentsDir, name)
}