本章总览
Agent Swarm 把多队友会话映射到 AppState 的 tasks、viewingAgentTaskId、teamContext 与 expandedView。本章以 teammateViewHelpers.ts 为核心,讲解 enter/exit/stop 三类 setState 转换;并串联 useSwarmInitialization(resume 恢复 team 文件)、useTeammateViewAutoExit(异常状态自动退出)、teamHelpers(磁盘 team 文件与 AppState.teamContext 对齐)。读完后应能解释 retain / evictAfter / diskLoaded 如何控制 transcript 内存与磁盘 bootstrap。
学完本章你应该能
- 说明 enterTeammateView 的 switching 与 needsRetain 逻辑
- 解释 release() 如何清 messages 并设置 evictAfter
- 描述 stopOrDismissAgent 对 running vs terminal 的分支
- 理解 useSwarmInitialization 的 resume vs fresh spawn 路径
- 说出 teamContext 与 viewingAgentTaskId 的职责差异
- 能在 REPL.tsx 找到 viewed local agent disk bootstrap effect
核心概念(先读懂这些)
viewingAgentTaskId 是 UI 视图指针
不是「当前活跃 agent」——活跃任务由 tasks 内 status 决定。viewingAgentTaskId 仅表示用户正在看哪条 task 的 transcript。输入路由由 selectors.getActiveAgentForInput 读取此指针 + task 类型。
retain 阻止 eviction 并触发 disk load
LocalAgentTask 默认 stub(无 messages)以省内存。enterTeammateView 对 local_agent 设 retain: true、清 evictAfter,REPL effect 见 needsBootstrap 时从 sessionStorage 拉 transcript。exitTeammateView 调用 release() 恢复 stub。
循环依赖与 inline type guard
teammateViewHelpers 刻意不 import LocalAgentTask 模块——会经 BackgroundTasksDialog 形成 cycle。isLocalAgent 内联 type === local_agent 检查;PANEL_GRACE_MS 与 framework.ts 数值同步注释。
建议学习步骤
- 阅读 release / enterTeammateView / exitTeammateView
- 阅读 stopOrDismissAgent abort vs dismiss
- 阅读 useTeammateViewAutoExit effect 条件
- 阅读 useSwarmInitialization resume 分支
- 浏览 teamHelpers TeamFile 结构与 readTeamFile
- 在 REPL 搜索 viewingAgentTaskId needsBootstrap
常见误区
注意
useTeammateViewAutoExit 用 taskExists 而非 viewedTask 判断 evict——local_agent 不会误退出
注意
completed teammate 不 auto-exit——用户可 review 全 transcript
注意
enterTeammateView 内 logEvent 无 PII,analytics 用 tengu_transcript_view_*
注意
teamContext.teammates 与 tasks 可能短暂不一致——以 tasks 为 UI 真相源
在架构中的位置
Teammate UI 状态机:
BackgroundTasksDialog 用户按 Enter 查看
→ enterTeammateView(taskId, setAppState)
→ viewingAgentTaskId = taskId, viewSelectionMode = viewing-agent
→ local_agent: retain=true → REPL disk bootstrap effect
→ 用户输入 → getActiveAgentForInput → viewed / named_agent
用户按 Esc / x
→ exitTeammateView 或 stopOrDismissAgent
→ release(): messages=undefined, evictAfter 可选
Swarm resume
→ useSwarmInitialization → initializeTeammateContextFromSession
→ setAppState teamContext + initializeTeammateHooks
AppState.teamContext 描述 swarm 成员元数据(tmux pane、cwd、color);tasks 持有运行时 TaskState(messages、status、abortController)。
release:stub 回收
release(task: LocalAgentTaskState) 共享逻辑:
retain: false— 允许 LRU evictionmessages: undefined— drop 内存 transcriptdiskLoaded: false— 下次 view 重新 bootstrapevictAfter— 若 status terminal,设为 Date.now() + PANEL_GRACE_MS(30s);否则 undefined
PANEL_GRACE_MS 与 BackgroundTasksDialog framework 一致——终端任务行在面板里短暂残留,避免「闪灭」。
release 不 abort running task——stopOrDismissAgent 负责 running 分支。
源码引用: src/state/teammateViewHelpers.ts · 第 23–38 行(共 142 行)
23| /**
24| * Return the task released back to stub form: retain dropped, messages
25| * cleared, evictAfter set if terminal. Shared by exitTeammateView and
26| * the switch-away path in enterTeammateView.
27| */
28| function release(task: LocalAgentTaskState): LocalAgentTaskState {
29| return {
30| ...task,
31| retain: false,
32| messages: undefined,
33| diskLoaded: false,
34| evictAfter: isTerminalTaskStatus(task.status)
35| ? Date.now() + PANEL_GRACE_MS
36| : undefined,
37| }
38| }
enterTeammateView
参数:taskId、setAppState。
setState 内优化:
- switching — 从另一 retained local_agent 切走 → 对 prevId release
- needsRetain — 目标 task 是 local_agent 且未 retain 或有 evictAfter → 设 retain true
- needsView — viewingAgentTaskId 或 viewSelectionMode 变化
- 三者皆否 →
return prev(Object.is bail-out)
返回新 state:viewingAgentTaskId、viewSelectionMode: 'viewing-agent'、可能更新的 tasks 浅拷贝。
logEvent('tengu_transcript_view_enter') analytics。
源码引用: src/state/teammateViewHelpers.ts · 第 40–81 行(共 142 行)
40| /**
41| * Transitions the UI to view a teammate's transcript.
42| * Sets viewingAgentTaskId and, for local_agent, retain: true (blocks eviction,
43| * enables stream-append, triggers disk bootstrap) and clears evictAfter.
44| * If switching from another agent, releases the previous one back to stub.
45| */
46| export function enterTeammateView(
47| taskId: string,
48| setAppState: (updater: (prev: AppState) => AppState) => void,
49| ): void {
50| logEvent('tengu_transcript_view_enter', {})
51| setAppState(prev => {
52| const task = prev.tasks[taskId]
53| const prevId = prev.viewingAgentTaskId
54| const prevTask = prevId !== undefined ? prev.tasks[prevId] : undefined
55| const switching =
56| prevId !== undefined &&
57| prevId !== taskId &&
58| isLocalAgent(prevTask) &&
59| prevTask.retain
60| const needsRetain =
61| isLocalAgent(task) && (!task.retain || task.evictAfter !== undefined)
62| const needsView =
63| prev.viewingAgentTaskId !== taskId ||
64| prev.viewSelectionMode !== 'viewing-agent'
65| if (!needsRetain && !needsView && !switching) return prev
66| let tasks = prev.tasks
67| if (switching || needsRetain) {
68| tasks = { ...prev.tasks }
69| if (switching) tasks[prevId] = release(prevTask)
70| if (needsRetain) {
71| tasks[taskId] = { ...task, retain: true, evictAfter: undefined }
72| }
73| }
74| return {
75| ...prev,
76| viewingAgentTaskId: taskId,
77| viewSelectionMode: 'viewing-agent',
78| tasks,
79| }
80| })
81| }
exitTeammateView 与 stopOrDismissAgent
exitTeammateView:
- 清 viewingAgentTaskId、viewSelectionMode → none
- 若当前 viewed task 是 retained local_agent → release 写回 tasks
- 若 id undefined 但 mode 非 none,仍清 mode(防御)
stopOrDismissAgent(taskId) — 面板 x 键:
| task.status | 行为 |
|---|---|
| running | abortController.abort(),不改 tasks |
| terminal | release + evictAfter=0 立即隐藏;若正在 view 则同时 exit |
evictAfter=0 使 filter 立刻隐藏行;比 grace 更激进。
源码引用: src/state/teammateViewHelpers.ts · 第 83–109 行(共 142 行)
83| /**
84| * Exit teammate transcript view and return to leader's view.
85| * Drops retain and clears messages back to stub form; if terminal,
86| * schedules eviction via evictAfter so the row lingers briefly.
87| */
88| export function exitTeammateView(
89| setAppState: (updater: (prev: AppState) => AppState) => void,
90| ): void {
91| logEvent('tengu_transcript_view_exit', {})
92| setAppState(prev => {
93| const id = prev.viewingAgentTaskId
94| const cleared = {
95| ...prev,
96| viewingAgentTaskId: undefined,
97| viewSelectionMode: 'none' as const,
98| }
99| if (id === undefined) {
100| return prev.viewSelectionMode === 'none' ? prev : cleared
101| }
102| const task = prev.tasks[id]
103| if (!isLocalAgent(task) || !task.retain) return cleared
104| return {
105| ...cleared,
106| tasks: { ...prev.tasks, [id]: release(task) },
107| }
108| })
109| }
源码引用: src/state/teammateViewHelpers.ts · 第 111–141 行(共 142 行)
111| /**
112| * Context-sensitive x: running → abort, terminal → dismiss.
113| * Dismiss sets evictAfter=0 so the filter hides immediately.
114| * If viewing the dismissed agent, also exits to leader.
115| */
116| export function stopOrDismissAgent(
117| taskId: string,
118| setAppState: (updater: (prev: AppState) => AppState) => void,
119| ): void {
120| setAppState(prev => {
121| const task = prev.tasks[taskId]
122| if (!isLocalAgent(task)) return prev
123| if (task.status === 'running') {
124| task.abortController?.abort()
125| return prev
126| }
127| if (task.evictAfter === 0) return prev
128| const viewingThis = prev.viewingAgentTaskId === taskId
129| return {
130| ...prev,
131| tasks: {
132| ...prev.tasks,
133| [taskId]: { ...release(task), evictAfter: 0 },
134| },
135| ...(viewingThis && {
136| viewingAgentTaskId: undefined,
137| viewSelectionMode: 'none',
138| }),
139| }
140| })
141| }
useTeammateViewAutoExit
Hook 订阅:
viewingAgentTaskIdtasks[viewingAgentTaskId]单 task 切片(非整 map——避免 streaming 重渲染)
effect 退出条件:
- task 从 map 消失(evict)→ exitTeammateView
- in-process teammate status killed/failed/有 error
- status 非 running/completed/pending 的异常态
不对 completed 退出——用户可读完 transcript。
local_agent:viewedTask 窄化为 undefined,status 检查跳过——不会误 exit。注释强调检查 raw taskExists。
源码引用: src/hooks/useTeammateViewAutoExit.ts · 第 6–63 行(共 64 行)
6| /**
7| * Auto-exits teammate viewing mode when the viewed teammate
8| * is killed or encounters an error. Users stay viewing completed
9| * teammates so they can review the full transcript.
10| */
11| export function useTeammateViewAutoExit(): void {
12| const setAppState = useSetAppState()
13| const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId)
14| // Select only the viewed task, not the full tasks map — otherwise every
15| // streaming update from any teammate re-renders this hook.
16| const task = useAppState(s =>
17| s.viewingAgentTaskId ? s.tasks[s.viewingAgentTaskId] : undefined,
18| )
19|
20| const viewedTask = task && isInProcessTeammateTask(task) ? task : undefined
21| const viewedStatus = viewedTask?.status
22| const viewedError = viewedTask?.error
23| const taskExists = task !== undefined
24|
25| useEffect(() => {
26| // Not viewing any teammate
27| if (!viewingAgentTaskId) {
28| return
29| }
30|
31| // Task no longer exists in the map — evicted out from under us.
32| // Check raw `task` not teammate-narrowed `viewedTask`; local_agent
33| // tasks exist but narrow to undefined, which would eject immediately.
34| if (!taskExists) {
35| exitTeammateView(setAppState)
36| return
37| }
38| // Status checks below are teammate-only (viewedTask is teammate-narrowed).
39| // For local_agent, viewedStatus is undefined → all checks falsy → no eject.
40| if (!viewedTask) return
41|
42| // Auto-exit if teammate is killed, stopped, has error, or is no longer running
43| // This handles shutdown scenarios where teammate becomes inactive
44| if (
45| viewedStatus === 'killed' ||
46| viewedStatus === 'failed' ||
47| viewedError ||
48| (viewedStatus !== 'running' &&
49| viewedStatus !== 'completed' &&
50| viewedStatus !== 'pending')
51| ) {
52| exitTeammateView(setAppState)
53| return
54| }
55| }, [
56| viewingAgentTaskId,
57| taskExists,
58| viewedTask,
59| viewedStatus,
60| viewedError,
61| setAppState,
62| ])
63| }
useSwarmInitialization
条件:isAgentSwarmsEnabled() 且 enabled prop。
Resume 路径(initialMessages[0] 含 teamName + agentName):
- initializeTeammateContextFromSession(setAppState, teamName, agentName)
- readTeamFile(teamName) 找 member.agentId
- initializeTeammateHooks(setAppState, sessionId, { teamName, agentId, agentName })
Fresh spawn:
- teamContext 已在 main.tsx computeInitialTeamContext 写入 initialState
- 仅需 getDynamicTeamContext() + initializeTeammateHooks
依赖 [setAppState, initialMessages, enabled]——resume 只跑一次 mount effect。
源码引用: src/hooks/useSwarmInitialization.ts · 第 22–81 行(共 82 行)
22| /**
23| * Hook that initializes swarm features when ENABLE_AGENT_SWARMS is true.
24| *
25| * Handles both:
26| * - Resumed teammate sessions (from --resume or /resume) where teamName/agentName
27| * are stored in transcript messages
28| * - Fresh spawns where context is read from environment variables
29| */
30| export function useSwarmInitialization(
31| setAppState: SetAppState,
32| initialMessages: Message[] | undefined,
33| { enabled = true }: { enabled?: boolean } = {},
34| ): void {
35| useEffect(() => {
36| if (!enabled) return
37| if (isAgentSwarmsEnabled()) {
38| // Check if this is a resumed agent session (from --resume or /resume)
39| // Resumed sessions have teamName/agentName stored in transcript messages
40| const firstMessage = initialMessages?.[0]
41| const teamName =
42| firstMessage && 'teamName' in firstMessage
43| ? (firstMessage.teamName as string | undefined)
44| : undefined
45| const agentName =
46| firstMessage && 'agentName' in firstMessage
47| ? (firstMessage.agentName as string | undefined)
48| : undefined
49|
50| if (teamName && agentName) {
51| // Resumed agent session - set up team context from stored info
52| initializeTeammateContextFromSession(setAppState, teamName, agentName)
53|
54| // Get agentId from team file for hook initialization
55| const teamFile = readTeamFile(teamName)
56| const member = teamFile?.members.find(
57| (m: { name: string }) => m.name === agentName,
58| )
59| if (member) {
60| initializeTeammateHooks(setAppState, getSessionId(), {
61| teamName,
62| agentId: member.agentId,
63| agentName,
64| })
65| }
66| } else {
67| // Fresh spawn or standalone session
68| // teamContext is already computed in main.tsx via computeInitialTeamContext()
69| // and included in initialState, so we only need to initialize hooks here
70| const context = getDynamicTeamContext?.()
71| if (context?.teamName && context?.agentId && context?.agentName) {
72| initializeTeammateHooks(setAppState, getSessionId(), {
73| teamName: context.teamName,
74| agentId: context.agentId,
75| agentName: context.agentName,
76| })
77| }
78| }
79| }
80| }, [setAppState, initialMessages, enabled])
81| }
teamHelpers 与 AppState.teamContext
TeamFile(磁盘 JSON)字段:name、leadAgentId、members[](agentId、tmuxPaneId、cwd、worktreePath、mode…)。
readTeamFile / writeTeamFile 在 ~/.claude/teams/(getTeamsDir)。
getSessionCreatedTeams(bootstrap)跟踪本 session TeamCreate 创建的团队,gracefulShutdown cleanup。
AppState teamContext 镜像运行时:
teamName, teamFilePath, leadAgentId
selfAgentId / selfAgentName / isLeader // 队友进程身份
teammates: { [id]: { name, tmuxPaneId, cwd, ... } }
SendMessage、sandbox permission mailbox 读 teamContext.teamName 路由。tmux 后端 spawn 时 populate teammates 字典。
源码引用: src/utils/swarm/teamHelpers.ts · 第 64–90 行(共 684 行)
64| export type TeamFile = {
65| name: string
66| description?: string
67| createdAt: number
68| leadAgentId: string
69| leadSessionId?: string // Actual session UUID of the leader (for discovery)
70| hiddenPaneIds?: string[] // Pane IDs that are currently hidden from the UI
71| teamAllowedPaths?: TeamAllowedPath[] // Paths all teammates can edit without asking
72| members: Array<{
73| agentId: string
74| name: string
75| agentType?: string
76| model?: string
77| prompt?: string
78| color?: string
79| planModeRequired?: boolean
80| joinedAt: number
81| tmuxPaneId: string
82| cwd: string
83| worktreePath?: string
84| sessionId?: string
85| subscriptions: string[]
86| backendType?: BackendType
87| isActive?: boolean // false when idle, undefined/true when active
88| mode?: PermissionMode // Current permission mode for this teammate
89| }>
90| }
源码引用: src/state/AppStateStore.ts · 第 323–345 行(共 570 行)
323| teamContext?: {
324| teamName: string
325| teamFilePath: string
326| leadAgentId: string
327| // Self-identity for swarm members (separate processes in tmux panes)
328| // Note: This is different from toolUseContext.agentId which is for in-process subagents
329| selfAgentId?: string // Swarm member's own ID (same as leadAgentId for leaders)
330| selfAgentName?: string // Swarm member's name ('team-lead' for leaders)
331| isLeader?: boolean // True if this swarm member is the team leader
332| selfAgentColor?: string // Assigned color for UI (used by dynamically joined sessions)
333| teammates: {
334| [teammateId: string]: {
335| name: string
336| agentType?: string
337| color?: string
338| tmuxSessionName: string
339| tmuxPaneId: string
340| cwd: string
341| worktreePath?: string
342| spawnedAt: number
343| }
344| }
345| }
REPL 与 expandedView 联动
expandedView 取值 'none' | 'tasks' | 'teammates':
- tasks → CoordinatorTaskPanel / todo 展开
- teammates → spinner tree 队友树
onChangeAppState 把 expandedView 映射到 legacy globalConfig(showExpandedTodos、showSpinnerTree)。
showTeammateMessagePreview 可选字段,ENABLE_AGENT_SWARMS feature DCE。
coordinatorTaskIndex、selectedIPAgentIndex 在 AppState 而非 PromptInput local state——避免 footer 与 panel prop drilling。
调试「看不到队友消息」:查 teamContext 是否初始化、tasks 是否有对应 agentId、viewingAgentTaskId 是否挡在 leader 输入路由。
源码引用: src/state/AppStateStore.ts · 第 95–108 行(共 570 行)
95| expandedView: 'none' | 'tasks' | 'teammates'
96| isBriefOnly: boolean
97| // Optional - only present when ENABLE_AGENT_SWARMS is true (for dead code elimination)
98| showTeammateMessagePreview?: boolean
99| selectedIPAgentIndex: number
100| // CoordinatorTaskPanel selection: -1 = pill, 0 = main, 1..N = agent rows.
101| // AppState (not local) so the panel can read it directly without prop-drilling
102| // through PromptInput → PromptInputFooter.
103| coordinatorTaskIndex: number
104| viewSelectionMode: 'none' | 'selecting-agent' | 'viewing-agent'
105| // Which footer pill is focused (arrow-key navigation below the prompt).
106| // Lives in AppState so pill components rendered outside PromptInput
107| // (CompanionSprite in REPL.tsx) can read their own focused state.
108| footerSelection: FooterItem | null
本章小结与延伸
teammate-state = transcript 视图状态机 + swarm 初始化。下一章 state-boundaries,读 AppState 之外的 bootstrap 与持久化层。 继续学习: