Claude Code 源码分析第 22 课 · 第 03
第22课

团队与 Swarm

Claude Code 如何启动多代理团队、通过邮箱路由消息、在工作人员之间同步权限以及在工作完成后干净利落地拆除所有内容。

TeamCreateTool tmux / iTerm2 / 处理中 邮箱消息传送 权限同步 团队文件结构

什么是群?

A swarm 是一个由 Claude 代理组成的指定团队,它们共享配置文件、任务列表和基于文件的邮箱。 一名代理人是 团队领导 ——它创建团队,产生队友,分配任务,并优雅地关闭一切。 每个其他代理都是一个 teammate.

该功能可通过以下方式选择加入 CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 环境变量,并通过两个一流的工具显示: TeamCreate and TeamDelete.

用户提示 │ ▼ 团队负责人 ──┬─── TeamCreate~/.claude/teams/my-team/config.json~/.claude/tasks/my-team/ │ ├──── 生成代理(研究员) ──► 研究人员窗格/进行中 ├──── 生成代理(测试员)──► 测试面板/进程中 │ │ ← 邮箱留言 → │ └──── TeamDelete → 清理目录和窗格

创建团队

当领导打电话时 TeamCreate,该工具在返回之前同步执行这些步骤:

1

唯一性检查

它调用 readTeamFile(team_name)。 如果该名称已存在,则随机生成一个新名称 generateWordSlug() 被替换,因此名称永远不会冲突。

2

编写团队配置

A TeamFile JSON 被写入 ~/.claude/teams/<sanitized-name>/config.json。 它使用领导者自己的条目为成员数组播种(name: "team-lead").

3

创建任务列表

Calls resetTaskList() and ensureTasksDir() 因此,该团队的任务编号从 1 开始。 还调用 setLeaderTeamName() 因此后续任务读取会转到正确的目录。

4

更新应用程序状态

Sets appState.teamContext 包含团队名称、文件路径、首席代理 ID 和初始队友地图。 这是 UI 读取的内容,用于显示团队状态徽章。

5

注册会话清理

将团队添加到内存中 Set via registerTeamForSessionCleanup()。 如果会话在没有显式退出的情况下退出 TeamDelete,关闭挂钩会自动清理孤立的目录。

来源:TeamCreateTool.ts — call() method
async call(input, context) {
  const { setAppState, getAppState } = context
  const appState = getAppState()

  // Guard: one team per leader at a time
  const existingTeam = appState.teamContext?.teamName
  if (existingTeam) {
    throw new Error(
      `Already leading team "${existingTeam}". Use TeamDelete first.`
    )
  }

  // Deduplicate name
  const finalTeamName = generateUniqueTeamName(team_name)
  const leadAgentId   = formatAgentId(TEAM_LEAD_NAME, finalTeamName)

  const teamFile: TeamFile = {
    name: finalTeamName,
    createdAt: Date.now(),
    leadAgentId,
    leadSessionId: getSessionId(),
    members: [{
      agentId:      leadAgentId,
      name:         TEAM_LEAD_NAME,     // "team-lead"
      agentType:    leadAgentType,
      model:        leadModel,
      joinedAt:     Date.now(),
      tmuxPaneId:   '',
      cwd:          getCwd(),
      subscriptions: [],
    }],
  }

  await writeTeamFileAsync(finalTeamName, teamFile)
  registerTeamForSessionCleanup(finalTeamName)

  await resetTaskList(taskListId)
  await ensureTasksDir(taskListId)
  setLeaderTeamName(sanitizeName(finalTeamName))

  setAppState(prev => ({
    ...prev,
    teamContext: {
      teamName:    finalTeamName,
      teamFilePath,
      leadAgentId,
      teammates: {
        [leadAgentId]: {
          name:         TEAM_LEAD_NAME,
          color:        assignTeammateColor(leadAgentId),
          spawnedAt:    Date.now(),
          ...
        }
      }
    }
  }))
}

磁盘布局

群将所有内容写在下面 ~/.claude/。 没有任何内容存储在全局或项目存储库中。

~/.claude/ ├── teams/ │ └── my-team/ ← 净化后的团队名称 │ ├── config.json ← TeamFile(成员、权限...) │ └── permissions/ │ ├── pending/ ← 工人的烫发请求 │ └── resolved/ ← 批准/拒绝回复 └── tasks/ └── my-team/ ← 该群的任务列表 ├── 0001.json ├── 0002.json └── ...
TeamFile 架构 — 每个字段都有解释
type TeamFile = {
  name:            string           // canonical team name
  description?:    string
  createdAt:       number           // epoch ms
  leadAgentId:     string           // "team-lead@my-team"
  leadSessionId?:  string           // session UUID of the leader
  hiddenPaneIds?:  string[]         // pane IDs moved to hidden window
  teamAllowedPaths?: TeamAllowedPath[]  // shared "always allow" edit rules
  members: Array<{
    agentId:        string   // "researcher@my-team"
    name:           string   // "researcher" — used for SendMessage
    agentType?:     string
    model?:         string
    prompt?:        string
    color?:         string
    planModeRequired?: boolean
    joinedAt:       number
    tmuxPaneId:     string   // "%3" tmux pane or iTerm session UUID
    cwd:            string
    worktreePath?:  string   // git worktree if isolated
    sessionId?:     string   // Claude session UUID
    subscriptions:  string[]
    backendType?:   'tmux' | 'iterm2' | 'in-process'
    isActive?:      boolean  // false = idle, undefined/true = running
    mode?:          PermissionMode
  }>
}
代理 ID 格式

代理 ID 始终遵循模式 agentName@teamName,例如 researcher@my-team。 队长的ID是确定的 team-lead@my-team。 队友之间总是这样称呼对方 name (不是 ID)调用 SendMessage 时。

管理队友的三种方法

当队友产生时,后端注册表会自动选择最佳的执行引擎。 优先级顺序是固定的,并且每个会话运行一次。

Backend 当选择时 窗格可见性 Hide/Show 杀戮方法
tmux 在 tmux 会话内(最高优先级)或 tmux 可用作后备 在同一窗口中分割窗格; 外部的 claude-swarm 会话(如果不在 tmux 内) 是的 - break-pane 到隐藏会话 kill-pane -t <paneId>
iterm2 在 iTerm2 中 it2 CLI 可用,并且用户尚未选择 tmux 首选项 当前窗口内的本机垂直分割窗格 否 — 不支持 it2 session close -f -s <id>
in-process 非交互式(-p 标志),显式 --teammate-mode in-process,或者没有可用的窗格后端 由 InProcessTeammateTask 组件内联渲染 N/A 通过 AbortController 中止
后端检测算法—— registry.ts detectAndGetBackend()
// Priority order (first match wins, cached for the session)

// 1. Running INSIDE tmux? Always use tmux.
if (await isInsideTmux()) {
  return { backend: createTmuxBackend(), isNative: true }
}

// 2. In iTerm2 with it2 CLI? Use iTerm2 (unless user prefers tmux).
if (isInITerm2()) {
  if (!getPreferTmuxOverIterm2()) {
    const it2Available = await isIt2CliAvailable()
    if (it2Available) {
      return { backend: createITermBackend(), isNative: true }
    }
  }
  // Fallback to tmux as external session if tmux is installed
  if (await isTmuxAvailable()) {
    return { backend: createTmuxBackend(), isNative: false, needsIt2Setup: true }
  }
}

// 3. Standalone terminal with tmux installed
if (await isTmuxAvailable()) {
  return { backend: createTmuxBackend(), isNative: false }
}

// 4. Nothing available → throw with platform-specific install instructions
throw new Error(getTmuxInstallInstructions())

检测使用捕获的环境变量 模块加载时间TMUX, TMUX_PANE, TERM_PROGRAM, 和 ITERM_SESSION_ID - 所以 shell 或后来的代码覆盖它们没有效果。

The it2 有意运行可用性检查 it2 session list (not it2 --version) 因为 --version 即使禁用 iTerm2 Python API,也会退出 0,这将导致窗格分割稍后静默失败。

tmux 后端 — 内部与外部 tmux 布局策略

在 tmux 内部(领导者位于窗格中): 第一个队友水平分割领导者的窗格 -l 70% 所以领导者保留了 30% 的窗口。 其他队友从现有队友窗格中垂直/水平交替分开,然后 select-layout main-vertical 重新平衡右侧。

tmux 之外(独立终端): 在 PID 范围的套接字上创建单独的 tmux 服务器(claude-swarm-<pid>)。 一个 claude-swarm 与一个会话 swarm-view 窗口已创建。 队友采用平铺布局。 用户可以使用以下方式附加到此会话 tmux -L claude-swarm-<pid> attach.

// Internal tmux socket name prevents conflicts between multiple Claude instances
export function getSwarmSocketName(): string {
  return `claude-swarm-${process.pid}`
}

窗格创建通过基于承诺的互斥体进行序列化(acquirePaneCreationLock())以防止并行生成多个队友时出现竞争状况。 创建每个窗格后,后端会休眠 200 毫秒,以允许 shell(zsh/bash、oh-my-zsh 等)在发送 Claude 命令之前完成初始化。

iTerm2 后端 — 故障死会话恢复

iTerm2 后端跟踪模块级数组中的窗格会话 UUID。 当产生额外的队友时,它通过以下方式定位最后一个已知的会话 UUID it2 session split -s <uuid>.

如果用户手动关闭窗格(Cmd+W),则下一次生成将失败并显示非零退出代码。 后端通过运行确认会话确实已死亡 it2 session list 并检查 UUID。 如果丢失,它会从数组中删除过时的 ID 并重试。 这个循环是有界的——每次迭代都会将数组缩小一,因此最坏情况下它会终止 N+1 iterations.

while (true) {
  const splitResult = await runIt2(splitArgs)
  if (splitResult.code !== 0 && targetedTeammateId) {
    const listResult = await runIt2(['session', 'list'])
    if (!listResult.stdout.includes(targetedTeammateId)) {
      teammateSessionIds.splice(idx, 1)   // prune dead session
      continue                               // retry with next-to-last
    }
    throw new Error(...)  // systemic failure — surface it
  }
  break
}
进程内后端 — 相同的 Node.js 进程,隔离上下文

进程内队友在领导者的 Node.js 进程内运行。 他们共享 API 客户端和 MCP 连接,但完全隔离 身份背景 via AsyncLocalStorage — 每个队友都有自己的代理名称、团队名称、颜色和 AbortController。

生成流程:

  1. spawnInProcessTeammate() 创建一个 TeammateContext 和一个独立的 AbortController (与领导者无关),注册一个 InProcessTeammateTaskState in AppState.tasks,并返回。
  2. InProcessBackend.spawn() 然后打电话 startInProcessTeammate() 作为一劳永逸的方式——代理循环在后台的事件循环上运行。
  3. 杀死用途 abortController.abort() 而不是杀死进程,并且任务状态转换为 'killed'.
// Teammates get an independent abort controller — interrupting the leader
// does NOT abort active teammates.
const abortController = createAbortController()

// Strip parent messages — teammates start with an empty conversation
toolUseContext: { ...this.context, messages: [] }

什么被发送到窗格

对于窗格支持的队友 (tmux/iTerm2),后端使用 buildInheritedCliFlags() and buildInheritedEnvVars() 构建在窗格内启动新的 Claude 实例的 shell 命令。

队友继承的 CLI 标志和环境变量
// Flags always forwarded if applicable:
'--dangerously-skip-permissions'  // if bypassPermissions mode active
'--permission-mode acceptEdits'   // if acceptEdits mode active
'--model <model>'                 // if set via CLI
'--settings <path>'               // if set via CLI
'--plugin-dir <dir>'              // for each inline plugin
'--teammate-mode <mode>'          // so nested spawns use same mode
'--chrome / --no-chrome'          // if set explicitly

// Env vars always set:
CLAUDECODE=1
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
CLAUDE_CODE_AGENT_COLOR=<color>   // assigned color for pane border

// Env vars forwarded if present in parent process:
CLAUDE_CODE_USE_BEDROCK  CLAUDE_CODE_USE_VERTEX  ANTHROPIC_BASE_URL
CLAUDE_CONFIG_DIR  CLAUDE_CODE_REMOTE  HTTPS_PROXY  ...

Plan模式比较特殊:if planModeRequired=true,绕过权限标志是 not 即使领导者设置了它们也会传播。 安全胜于便利。

队友如何沟通

每个代理(无论后端如何)都通过 基于文件的邮箱。 消息是写入的 JSON 文件 ~/.claude/teams/<team>/inbox/<agent-name>/。 领导者会轮询这些收件箱,并在新对话发生时传递消息。

researcher │ │ writeToMailbox("team-lead", { │ 来自: "researcher", │ 文字: “分析完成。参见tasks/0003。”, │ 时间戳: "2025-..." │ }, 团队名称) │ ▼ ~/.claude/teams/my-team/inbox/team-lead/<uuid>.json │ │ (领导者收件箱轮询器读取文件,作为用户消息传递) │ ▼ 团队负责人 接收消息作为新的对话轮次
自动发货

潜在客户从不手动“检查”其收件箱。 收件箱轮询器根据文件系统事件唤醒,并在合成用户轮流时传递排队的消息。 如果领导者处于轮中,则消息将排队并在当前轮结束时传递。

流经邮箱的消息类型
TypeDirectionPurpose
纯文本任何→任何任务更新、问题、结果
shutdown_request领导→队友优雅的关闭信号
空闲通知队友→领导每回合结束后系统生成
permission_request工人→领导Worker 需要 UI 批准工具
permission_response领导→工人批准/拒绝工具使用
mode_set_request领导→队友更改队友的权限模式
sandbox_permission_request工人→领导工作人员需要网络访问批准
纯消息中没有结构化 JSON

队友永远不应该发送原始 JSON 对象,例如 {"type":"idle",...} 作为用户消息。 只有系统发送结构化消息类型。 代理之间的简单通信始终是自然语言。

Worker → Leader 权限提升

当工作代理遇到它没有权限的工具使用时,它可以通过邮箱升级给团队领导,而不是默默地失败。 领导者在其 UI 中看到请求(标准 ToolUseConfirm 对话框)并批准或拒绝它。

worker 需要运行 编辑(“src/api.ts”,...) │ ├── createPermissionRequest({ 工具名称, 输入, 描述, ... }) │ ├── sendPermissionRequestViaMailbox(请求) │ └─► 写入 inbox/team-lead/<uuid>.json │ │ [领导者的UI显示ToolUseConfirm对话框] │ [用户批准或拒绝] │ ├── sendPermissionResponseViaMailbox(workerName、resolution、requestId) │ └─► 写入 inbox/<workerName>/<uuid>.json │ └── pollForResponse(requestId) ← 工作轮询直到解决返回 { 决策: "approved" | "denied",更新输入?,权限更新? }
权限请求架构和锁定
type SwarmPermissionRequest = {
  id:                  string   // "perm-<ts>-<random>"
  workerId:            string   // "researcher@my-team"
  workerName:          string
  workerColor?:        string
  teamName:            string
  toolName:            string   // e.g. "Edit"
  toolUseId:           string
  description:         string   // human-readable summary
  input:               Record<string, unknown>
  permissionSuggestions: unknown[]
  status:              'pending' | 'approved' | 'rejected'
  resolvedBy?:         'worker' | 'leader'
  resolvedAt?:         number
  feedback?:           string
  updatedInput?:       Record<string, unknown>
  permissionUpdates?:  unknown[]
  createdAt:           number
}

对挂起目录的写操作使用 .lock 文件与 proper-lockfile 语义,以便并发工作人员不会破坏彼此的请求。

领导者权限桥(仅限进程内)

对于正在处理的队友,权限提示直接显示在领导者的终端 UI 中,而不是通过文件系统。 桥接模块(leaderPermissionBridge.ts) 是一个模块级注册表,用于存储 REPL 在启动时注册的 React setter 函数:

// In leaderPermissionBridge.ts — no React dependency
let registeredSetter: SetToolUseConfirmQueueFn | null = null

export function registerLeaderToolUseConfirmQueue(setter): void {
  registeredSetter = setter
}
export function getLeaderToolUseConfirmQueue() {
  return registeredSetter
}

进程内运行程序调用 getLeaderToolUseConfirmQueue() 当它需要显示确认对话框时,完全绕过基于文件的权限系统。

端到端团队工作流程

第一阶段——设置

  • 潜在客户来电 TeamCreate (创建 config.json + 任务目录)
  • 领导创建任务 TaskCreate
  • 领先会产生队友 Agent 工具(tmux/iTerm2/进程中)
  • 队友加入团队,将他们的条目写入config.json

第 2 阶段 — 并行工作

  • 队友检查 TaskList, 声明未分配的任务
  • 队友工作,通过发送进度消息 SendMessage
  • 每回合结束后,队友自动发送空闲通知
  • 领导分配新任务或发送后续工作

第 3 阶段 — 关闭

  • 潜在客户发送 {"type":"shutdown_request"} 到每个队友的邮箱
  • 队友批准,退出 — 设置 isActive: false
  • 领导等待直至不再有活跃的非领导成员
  • 潜在客户来电 TeamDelete — 清理目录、杀死窗格、清除 AppState

第 4 阶段 — 清理卫士

  • 如果会话结束时没有 TeamDelete (SIGINT,崩溃),关闭钩子调用 cleanupSessionTeams()
  • 窗格支持的窗格首先被杀死(因此僵尸进程不会在 tmux 中徘徊)
  • 然后删除团队和任务目录

清理团队

TeamDelete 无需输入——团队名称来自 appState.teamContext.teamName。 如果任何成员仍然有,它将拒绝运行 isActive !== false,迫使领先者先优雅地关闭队友。

来源:TeamDeleteTool.ts — call() method
async call(_input, context) {
  const teamName = getAppState().teamContext?.teamName

  if (teamName) {
    const teamFile = readTeamFile(teamName)
    const nonLeadMembers = teamFile.members
      .filter(m => m.name !== TEAM_LEAD_NAME)
    const activeMembers = nonLeadMembers
      .filter(m => m.isActive !== false)   // idle = false, running = true/undefined

    if (activeMembers.length > 0) {
      return { success: false, message: `Cannot cleanup: ${memberNames} still active` }
    }

    await cleanupTeamDirectories(teamName)  // teams/ + tasks/ + git worktrees
    unregisterTeamForSessionCleanup(teamName)
    clearTeammateColors()
    clearLeaderTeamName()
  }

  setAppState(prev => ({
    ...prev,
    teamContext: undefined,
    inbox: { messages: [] },   // flush queued messages
  }))
}

The cleanupTeamDirectories() 函数首先读取要收集的配置 worktreePath 条目,然后销毁每个 git 工作树 git worktree remove --force (回落到 rm -rf 如果失败),最后删除 ~/.claude/teams/ and ~/.claude/tasks/ directories.

团队状态和团队对话框

团队状态(页脚徽章)

Reads appState.teamContext.teammates 并计算非主要成员。 在页脚显示一个药丸 N名队友。 选择后,按 Enter 键将打开 TeamsDialog。 当没有队友处于活动状态时返回 null(零队友 = 无渲染成本)。

TeamsDialog(交互式面板)

两级导航: 列表视图 显示所有队友的状态、模式和活动/空闲状态; 详细视图 让领导循环队友的权限模式(默认→acceptEdits→bypassPermissions→plan),杀死队友,或跳转到其 tmux/iTerm2 窗格。 以 1 秒间隔刷新以获取队友写入 config.json 的模式更改。

TeamsDialog 如何循环权限模式

在列表视图中,按循环模式键调用 cycleAllTeammateModes() 它使用 setMultipleMemberModes() 对于原子多写,防止顺序单写的 TOCTOU 问题。

在详细视图中,它仅循环选定的队友 setMemberMode(),然后发送一个 mode_set_request 消息发送到队友的邮箱,以便在没有文件监视轮询延迟的情况下通知正在运行的进程。

// Atomic update of all member modes in one config.json write
setMultipleMemberModes(teamName, [
  { memberName: 'researcher', mode: 'acceptEdits' },
  { memberName: 'tester',     mode: 'acceptEdits' },
])

要点

  1. 团队 = 任务列表。 TeamCreate 下创建一个匹配的任务目录 ~/.claude/tasks/, 和 sanitizeName(teamName) 是领导和队友用来查找任务的共享密钥。
  2. 三个后端,一个接口。 The TeammateExecutor 接口抽象 tmux、iTerm2 和进程内。 注册表在每个会话中检测并缓存一次正确的后端; 呼叫者从不检查 process.env.TMUX directly.
  3. 检测是在模块加载时捕获的。 TMUX, TMUX_PANE, 和 ITERM_SESSION_ID 在导入时读入模块级常量。 这是有意为之的——shell 稍后会覆盖 TMUX 对于它自己的套接字,系统不得将其与“用户位于 tmux 内部”混淆。
  4. 所有消息传递都是基于文件的。 即使是进程中的队友也使用邮箱来发送消息。 唯一的例外是领导者权限桥,它使用模块级设置器将权限对话框推送到 React UI,而无需文件 I/O。
  5. 闲着没死。 队友写 isActive: false 每回合后到 config.json 。 领导者不得发出关机信号或做出警报反应——当新消息到达邮箱时,闲置的队友会正常醒来。
  6. TeamDelete 会阻止活跃成员。 它检查 isActive !== false 决定成员是否仍在运行。 在调用 TeamDelete 之前,请务必关闭队友(shutdown_request→approve→isActive=false)。
  7. 会话清理是一个安全网。 registerTeamForSessionCleanup() 确保即使会话在没有显式 TeamDelete 的情况下崩溃,孤立的窗格和目录也会在正常关闭时被清除。

测验

1. 当从现有 tmux 会话内部启动 Claude 时,即使 iTerm2 是终端模拟器,也会选择哪个后端?

a) iTerm2 后端,因为它是本机终端模拟器
b) tmux 后端 — 位于 tmux 内部是最高优先级条件
c) 进程内后端,因为 iTerm2 没有 tmux 套接字
d) 抛出错误要求用户选择

2. 队友完成分配的任务并呼叫 TaskUpdate 将它们标记为完整。 领导看到团队配置中列出的成员 isActive: false。 这意味着什么?

a) 队友进程崩溃了,应该重新生成
b) 队友空闲——当前回合结束; 当新消息到达时它会唤醒
c) 队友永久关机,无法接收更多消息
d) 现在可以安全地调用 TeamDelete,而无需发送关闭请求

3.在哪里 TeamCreate 存储团队的配置文件?

a) ~/.claude/sessions/<team-name>/config.json
b) ~/.claude/agents/<team-name>.json
c) ~/.claude/teams/<sanitized-name>/config.json
d) <project-cwd>/.claude/team.json

4. 为什么会这样 isIt2CliAvailable() run it2 session list 而不是 it2 --version?

a) --version 不是 it2 CLI 的有效标志
b) --version 即使禁用 iTerm2 Python API 也会退出 0,因此稍后 session split 会默默地失败
c) session list 更快,因为它使用缓存的结果
d) it2 CLI 需要会话列表在任何其他命令之前进行身份验证

5. 当团队领导会话通过 Ctrl-C (SIGINT) 退出而不调用 TeamDelete,孤立的队友窗格和目录会发生什么情况?

a) 它们保留在磁盘上和 tmux 中; 用户必须手动清理它们
b) 只删除config.json; 窗格保持打开状态
c) cleanupSessionTeams() 首先终止窗格支持的队友进程,然后删除团队和任务目录
d) TeamDelete 在下一个 Claude 会话开始时自动调用

6. 在 tmux 模式下运行的队友有 planModeRequired: true。 主导会议有 bypassPermissions 积极的。 当队友的spawn命令构建时会发生什么?

a) --dangerously-skip-permissions 被包括在内,因为引线已启用旁路
b) --dangerously-skip-permissions is not 包含 - 计划模式优先于旁路权限
c) 两者 --dangerously-skip-permissions and --plan-mode-required 包括在内
d) 由于计划模式和绕过权限不能共存而抛出错误