Note
Team Lead 协作:持久成员、共享任务与运行恢复
How PseudoClaude models persistent teams, launches isolated in-process members, shares dependency-aware tasks, and reports member completion to the Lead.
普通后台子 Agent 的生命周期围绕一次 Task,完成后只保留内存 Conversation。Team 要解决的是另一类问题:Lead 需要稳定的成员身份、共享任务状态、独立工作区和可继续的通信地址,成员空闲后仍能被下一条消息唤醒。
PseudoClaude 因此把 Team 状态写到 ~/.PseudoClaude/teams,每个 Member 拥有 AgentID、Session、Worktree 和 Mailbox。当前真正接通的执行后端是进程内 Task Manager;tmux/iTerm2 和角色/Plan 字段已经建模,但还没有进入生产 Spawn 链。
Team 是磁盘状态,不是 goroutine 列表
核心类型把团队身份和运行位置分开保存:
type Team struct {
Name string `json:"name"`
SanitizedName string `json:"sanitized_name"`
Description string `json:"description,omitempty"`
ProjectRoot string `json:"project_root"`
LeadAgentID string `json:"lead_agent_id"`
Backend BackendType `json:"backend"`
CreatedAt time.Time `json:"created_at"`
Members []MemberInfo `json:"members"`
ConfigDir string `json:"-"`
ConfigPath string `json:"-"`
InboxDir string `json:"-"`
TasksPath string `json:"-"`
}
type MemberInfo struct {
Name string `json:"name"`
AgentID string `json:"agent_id"`
AgentType string `json:"agent_type,omitempty"`
Model string `json:"model,omitempty"`
WorktreeName string `json:"worktree_name"`
WorktreePath string `json:"worktree_path"`
Branch string `json:"branch"`
BackendType BackendType `json:"backend_type"`
IsActive *bool `json:"is_active,omitempty"`
PlanModeRequired bool `json:"plan_mode_required"`
SessionID string `json:"session_id"`
SessionDir string `json:"session_dir"`
LastUpdatedAt time.Time `json:"last_updated_at"`
}
配置目录由清洗后的 Team 名派生:
~/.PseudoClaude/teams/<sanitized-name>/
config.json
tasks.json
inboxes/
sessions/<agent-id>/
Manager 初始化会遍历该目录并恢复合法 config.json;损坏 Team 被 Warning 跳过,不阻止其他 Team 使用。普通后台 Task 不持久化,Team 配置虽能恢复 Member 元数据,但进程内 Runner 和 Conversation 仍在 Task Manager 内存中,进程重启后不能仅凭 config 恢复成员的执行上下文。
TeamCreate 同时建立 Lead 和共享存储
创建时先规范化目录名,默认 Backend 为 in-process,再建立 Inbox、空任务文件和可选 Lead 成员:
func (m *Manager) Create(
ctx context.Context,
in CreateInput,
) (*Team, error) {
base := SanitizeName(in.Name)
safe, err := uniqueSanitizedName(m.root, base)
if err != nil {
return nil, err
}
backend := in.Backend
if backend == "" {
backend = BackendInProcess
}
team := &Team{
Name: in.Name,
SanitizedName: safe,
Description: in.Description,
ProjectRoot: m.projectRoot,
LeadAgentID: in.LeadAgentID,
Backend: backend,
CreatedAt: time.Now(),
}
derivePaths(team, m.homeDir)
if err := os.MkdirAll(team.InboxDir, 0o755); err != nil {
return nil, err
}
if err := atomicWriteJSON(
team.TasksPath,
map[string]any{"tasks": []any{}},
); err != nil {
return nil, err
}
if team.LeadAgentID != "" {
team.Members = append(team.Members, MemberInfo{
Name: "lead", AgentID: team.LeadAgentID,
BackendType: backend, LastUpdatedAt: time.Now(),
})
}
if err := team.save(); err != nil {
return nil, err
}
// 写入 Manager map 与名称 Registry
return cloneTeam(team), nil
}
Team Name 可以包含空格和 Unicode,SanitizeName 会转小写并用 - 收敛不支持字符。同名安全目录已存在时追加 -2、-3,所以显示名称相同也可能创建多个持久 Team。
JSON 使用临时文件加 os.Rename 更新,但 Team Create 没有包住多文件事务。Inbox 创建成功而 tasks/config 写入失败时,磁盘上可能留下不完整目录。
SpawnMember 先创建 Worktree 和初始 Mailbox
Team Member 与普通 Defined 子 Agent 最大的差异是 worktree 不是可选项:
func (m *Manager) SpawnMember(
ctx context.Context,
in agent.TeamLaunchInput,
) (agent.TeamLaunchResult, error) {
team, ok := m.getInternal(in.TeamName)
if !ok {
return agent.TeamLaunchResult{}, ErrTeamNotFound
}
memberName := SanitizeName(in.MemberName)
if memberName == "" {
memberName = "member"
}
if m.worktrees == nil {
return agent.TeamLaunchResult{}, ErrWorktreeDisabled
}
if team.Backend == BackendInProcess {
if m.tasks == nil || in.Parent.Registry == nil ||
in.Parent.Provider == nil {
return agent.TeamLaunchResult{}, ErrBackendDisabled
}
}
agentID := "team-" + team.SanitizedName + "-" +
memberName + "-" + fmt.Sprintf("%d", time.Now().UnixNano())
worktreeName := "team-" + team.SanitizedName + "-" + memberName
wt, err := m.worktrees.Create(
ctx, worktree.CreateInput{Name: worktreeName},
)
if err != nil {
return agent.TeamLaunchResult{}, err
}
// 创建 session dir、写初始 mailbox、保存 MemberInfo
}
成员名决定稳定 Worktree 名,AgentID 再追加纳秒时间。Member 元数据保存前,Manager 会把 Lead 的 Prompt 写进该 AgentID 的 Mailbox;之后才 AddMember 和启动 Backend。这样成员第一轮既收到 User Prompt,也能在 Reminder 看到同一份初始消息。
创建步骤没有统一 rollback。Worktree 建好后若 SessionDir、Mailbox 或 AddMember 失败,当前路径不会自动清理已创建资源;进程内 Launch 失败只移除 Member 和 Registry,也会保留先前 Worktree、SessionDir 和 Mailbox。
进程内成员使用独立 Runner 和 Conversation
已接通的 Backend 会从父 Snapshot 组装 Team Runner:
runner := agent.Runner{
Provider: parent.Provider,
Registry: parent.Registry,
Env: parent.Env,
Config: parent.Config,
Permission: parent.Permission,
Instructions: parent.Instructions,
AllowedTools: toolkit.FilterSubAgentTools(
parent.Registry,
toolkit.FilterPolicy{
TeamMember: true,
InProcessTeamMember: true,
Background: true,
},
),
SessionID: member.SessionID,
CWD: member.WorktreePath,
Team: &agent.TeamRunContext{
TeamName: team.Name,
MemberName: member.Name,
AgentID: member.AgentID,
LeadID: team.LeadAgentID,
Inbox: AgentInbox{Store: store},
},
Sub: agent.SubRunOptions{
SystemPrompt: BuildMemberPrompt(*team, member),
IsSubAgent: true,
ParentLabel: member.Name,
DontAsk: true,
FileCacheScope: member.AgentID,
PermissionMode: parent.PermissionMode,
},
}
runner.Env.CWD = member.WorktreePath
runner.Env.Team = &toolkit.TeamEnv{
TeamName: team.Name,
MemberName: member.Name,
AgentID: member.AgentID,
}
Team Member 使用全新的 Conversation,但共享父 Provider、Registry、Permission Engine 和 Instructions。可用 Tool 限定为后台基础工具加 TaskCreate/Update/List/Get 与 SendMessage,仍禁止 Agent Tool,因此成员不能继续扩张团队。
FileCacheScope 使用 AgentID,避免多个成员的文件缓存混在同一 Scope;CWD 同时写入 Runner 与 Tool Env,模型环境、文件工具和权限检查都指向成员 Worktree。
当前 AgentType、Model 和 PlanModeRequired 只保存到 MemberInfo。launchInProcess 不从 Subagent Catalog 解析角色,不切换 Provider,也不根据 PlanModeRequired 阻止执行;BuildMemberPrompt 只注入通用团队规则。它们是已经建模但尚未接线的策略字段。
Team Task 是共享依赖图
普通 BackgroundTask 表示一个正在运行的 goroutine;Team Task 则是 tasks.json 中的协作工作项:
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Assignee string `json:"assignee,omitempty"`
Status Status `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
BlockedBy []string `json:"blocked_by,omitempty"`
Blocks []string `json:"blocks,omitempty"`
}
func isReady(task Task, byID map[string]Task) bool {
for _, id := range task.BlockedBy {
if byID[id].Status != StatusDone {
return false
}
}
return true
}
TaskUpdate 在添加 BlockedBy 时同步更新另一端的 Blocks,删除时也双向维护。List 为每项计算 IsReady,方便 Lead 选择下一项工作。Store 使用文件锁和原子 JSON 写入,所以多个进程内成员能共享一个任务文件。
当前 Store 不检查依赖 ID 是否存在、不检测环,也不验证 Tool 输入的 Status 是否属于四个常量。缺失依赖会因为零值状态不是 Done 而永久阻塞,循环依赖也只能由协作者手动修正。
成员完成后变为空闲并回报 Lead
进程内成员以 AgentID 作为 Task ID 和 Name 启动。OnFinish 负责持久化活动状态并写 Lead Mailbox:
func (m *Manager) markMemberIdleAndNotify(
ctx context.Context,
team *Team,
member MemberInfo,
event task.FinishEvent,
) {
if err := team.SetMemberActive(
m.homeDir, member.AgentID, false,
); err != nil {
m.warn("failed to mark member idle %s/%s: %v",
team.Name, member.Name, err)
}
if team.LeadAgentID == "" {
return
}
store, err := mailbox.New(team.InboxDir)
if err != nil {
return
}
summary := "member idle: " + string(event.Snapshot.Status)
_ = store.Write(ctx, team.LeadAgentID, mailbox.Message{
From: member.AgentID,
To: team.LeadAgentID,
Type: mailbox.MessageText,
Summary: summary,
Content: event.Snapshot.Result,
Payload: map[string]any{
"member_name": member.Name,
"task_id": event.TaskID,
"status": event.Snapshot.Status,
"error": event.Snapshot.Error,
},
})
}
Lead 的 TUI 会轮询是否有未读消息,空闲时自动提交一轮“Process unread team updates”,或者在下一次正常请求中把 LeadReminder 注入模型。成员结果因此通过 Mailbox 而不是直接合并 Conversation。
当 SendMessage 写给一个空闲的 in-process Member 时,ResumeMember 先把 IsActive 改回 true,再调用 Task Manager 的 SendMessage,复用原 Runner 与 Conversation,并用固定 wake Prompt 提醒它读取 Mailbox。若 PseudoClaude 已重启,Task Manager 中没有这个 Name,持久 Member 也无法恢复执行。
Backend 与 Plan 字段的当前接线状态
类型包定义 tmux、iterm2 和 in-process,internal/team/backend 也声明了 Spawn/Wake/Kill 接口和环境检测。但是生产 main.go 创建 Team Manager 时没有注入 BackendController,SpawnMember 只在 BackendInProcess 分支调用 launchInProcess。
如果 TeamCreate 显式写 backend: tmux 或 iterm2,当前代码会创建 Worktree、SessionDir、Mailbox 和 active MemberInfo,却不会调用外部 Backend Spawn,返回结果中的 PaneID 为空。Delete/Kill 也因 Manager backend 为 nil,不会终止外部进程。
plan_mode_required 同样只持久化;Mailbox 对 plan_approval_response 唯一执行的规则是只有 Lead 能发送该类型,并没有成员提交 Plan、等待批准后再执行的状态机。这些不能作为已完成的产品能力介绍。
完整流程
Lead 调用 TeamCreate
-> 建立 ~/.PseudoClaude/teams/<name>
-> 保存 lead Member + tasks.json + inboxes
Lead 调用 Agent(team_name, member_name, prompt)
-> 创建 team-<team>-<member> worktree/branch
-> 建 SessionDir 和成员 Mailbox
-> 写初始 Prompt 消息
-> 保存 MemberInfo(active=true)
-> in-process: 创建独立 Team Runner + Conversation
-> Task Manager 后台执行
-> Member 使用共享 Task Tool 与 SendMessage
-> 完成后 active=false,结果写入 Lead Mailbox
-> Lead Reminder 消费结果
-> 后续消息写给空闲 Member:复用 Task Conversation 再启动
测试验证了什么
Team Manager 测试覆盖创建、磁盘恢复、损坏配置隔离、成员增删和强制删除;Spawn 测试覆盖 Worktree、初始 Mailbox、独立 Team Runner、完成后空闲与 Lead 通知、空闲成员恢复;TeamTask 测试覆盖双向依赖与 IsReady;Tool 测试覆盖 Create/Delete/Kill 和共享 Task 操作。
现有测试没有证明 tmux/iTerm2 真正 Spawn,也没有覆盖 AgentType/Model/PlanModeRequired 的运行时效果、跨进程成员恢复、依赖环或 Spawn 中途失败的资源 rollback。
小结
PseudoClaude 的 Team 不是临时并发列表,而是一组落盘身份、Worktree、Mailbox 和共享任务。已完成的主链是 in-process Member:独立 Runner 在后台执行,结束后持久化为空闲并通过 Mailbox 回报 Lead,后续消息可以复用其 Conversation。
下一篇下钻这条协作链的通信层:每个 Agent 的 JSON Mailbox 如何应对并发写入,未读消息怎样变成模型 Reminder,以及点对点、广播与控制消息如何路由。