ARTICLE · 1165114
dsh-agent-teams源码阅读:它把“队长带团队”写成了十件小事

之前读了 Claude Code 的源码:Subagent、Fork、Teammate、coordinator,四套协作机制。这篇换个对象,看一个把同一套"队长带团队"模式真正做成产品的开源插件——dsh-agent-teams,跑在 DeepSeek Harness(下称 DSH)上。
它不讲概念,它交付一套能用的东西:装进 DSH,用一句自然语言就能拉起一支团队。当前会话自动成为队长,按角色拉出若干成员,把目标拆成带依赖的任务,成员之间还能互相直接发消息,右上角挂着一个实时活动面板盯着进度。
一、先弄清楚它是什么:不是新运行时,是把现成的接缝拼起来
它本体是一个 DSH 的 bundle 插件,同时扮演两个角色:host 面(Node 侧)注册工具和服务,client 面(浏览器侧)渲染那个活动面板。装完之后,这个 DSH profile 下的任意会话都能用自然语言驱动它。
它的入口文件很能说明设计取向:对模型暴露的接口只有两件事——往全局 tools 注册表里注册十个 agent_teams_* 工具,往系统提示词里插一段"使用协议"。(此外还挂了两条 Web 路由,给活动面板供数据和头像图片,那是浏览器侧的活儿,第七节再讲。)
const toolNames = [ 'agent_teams_create', 'agent_teams_add_member', 'agent_teams_remove_member', 'agent_teams_create_task', 'agent_teams_reassign_task', 'agent_teams_claim_task', 'agent_teams_update_task', 'agent_teams_send_message', 'agent_teams_status', 'agent_teams_delete', ].join(', ')十个工具,就是"队长带团队"的全部动作面。没有第十一个。
官方文档里有一张对照表,把插件的每一处能力都映射到 DSH 已存在的接缝上:
ctx.tools | |
ctx.subagents.startContinuable() | |
ctx.subagents.followup() | |
ctx.subagents.listChildren()ctx.agents | |
agent/status | |
ctx.systemPrompt.section() | |
换句话说,它没有引入 workflow 引擎,也没有自定义一套调度运行时,而是把 DSH 本来就有的"能力接缝"重新组合了一遍。这是我读下来第一个觉得舒服的地方:做基础设施,最先该问的不是"我要加什么",而是"现有的东西能拼出什么"。
二、状态层:一个团队,本质上就是一个目录
插件把团队状态落在磁盘上:
<workspace>/.agent-teams/<teamId>/ ├── team.json # 团队记录:成员、任务(含依赖)、任务序号 └── inbox/ ├── captain.jsonl # 队长的邮箱(成员 → 队长) └── <member>.jsonl # 每个成员一个邮箱(JSONL)一个团队就是一个目录、一个 JSON 文件,外加每人一个 JSONL 邮箱。这个做法很朴素,但和 Claude Code 那边"Team = Project = TaskList"的思路是一脉相承的——用磁盘上的目录结构,直接表达组织关系。
朴素归朴素,但底下有两件事做得很细。
第一件,所有写入都是串行的。 团队状态被一个进程内的 promise 链锁住:
export async function withTeamLock<T>(key: string, fn: () => Promise<T>): Promise<T> { const previous = locks.get(key) ?? Promise.resolve() let release!: () => void const gate = new Promise<void>((resolve) => { release = resolve }) locks.set(key, previous.then(() => gate)) await previous try { return await fn() } finally { release() } }读-改-写的整个过程被关在锁里。配套的落盘函数也做成了"临时文件 + rename"的原子替换,防止写到一半崩掉留下半截 JSON。不过这把锁是进程内的——docs/usage.md 的"已知限制"里写得很诚实:多个 DSH 进程同时改同一个团队,不保证一致。
第二件,一个关于文件名的教训,藏在一段注释里。 队友名字会被折叠成合法的路径片段,实现函数叫 sanitizeKey()。它的注释是这么写的:
An ASCII-only whitelist mapped *every* non-Latin name onto one shared fallback, which silently merged their mailboxes and rejected the second such member as a duplicate.
翻过来就是:早期版本用一个纯 ASCII 的字符白名单,结果所有非拉丁名字都被映射到了同一个兜底值——邮箱被静默合并,第二个中文名成员还会被当成重名直接拒绝。所以后来改成了 Unicode 感知的写法,用 \p{L}\p{N} 保留字母和数字(中文、西里尔、希腊字母都能留下),纯 emoji 或标点这种确实没法生成可读 key 的,才退化成一段哈希摘要;过长的名字则截断后补摘要(因为 CJK 在 UTF-8 下每字符占 3 字节,不截会顶到文件系统上限)。
这种注释就是读源码的乐趣所在——它不是在解释代码干了什么,而是在记录代码曾经错过什么。
三、真正的核心:attempt 令牌,解决"迟到写入覆盖新结果"
任务有一套显式状态机:
export const TASK_TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = { pending: ['claimed', 'cancelled'], claimed: ['in_progress', 'failed', 'cancelled'], in_progress: ['completed', 'failed', 'cancelled'], completed: [], failed: [], cancelled: [], }终态没有出边,也就是说 completed/failed/cancelled 之后不可再改。这一点后面会反复用到。
但状态机本身不难,难的是并发。设想一个再常见不过的场景:队长发现某个成员把任务做砸了,于是把任务转派给另一个人,或者干脆自己接手。问题是,那个被撤掉的旧成员,可能还在跑它上一轮的活儿,跑完之后它会调用 agent_teams_update_task 把结果写回去——如果这行写入在转派之后才落地,它就会覆盖掉新成员的结果。
这个插件给出的答案是一套"能力令牌 + 代次"的机制。每个任务上挂着三个字段:
attempt:单调递增的执行代次; attemptId:当前这一次执行的能力凭证,每次开启都是一个新的 UUID; handoffId+ reassigning:一次还没开始下一轮的转派过程。
转派时,第一个动作不是去中断旧成员,而是先作废旧凭证:
/** * Revoke the current worker immediately. Clearing its capability makes old * updates stale; a separate handoff generation serializes async quiescence. */ export function invalidateTaskAttempt( task: TeamTask, nextAssignee?: string, reassigning = false, ): void { task.attemptId = undefined task.handoffId = randomUUID() task.status = 'pending' task.assignee = nextAssignee task.reassigning = reassigning task.output = undefined task.updatedAt = Date.now() }attemptId 一清空,旧成员手里那个凭证就立刻过期了。它之后无论写什么,都会被工具层顶回来——成员更新任务时必须带上当前 attempt_id,对不上就是一行干脆的报错:
stale attempt for task ...: expected the current attempt_id; stop work and request fresh assignment
然后才是"中断旧成员 → 等它真正安静下来 → 落最终状态"这一串异步收敛。整套顺序严格排成:先撤权,再中断,等安静,最后定稿。源码把这条原则写得很直白——转派"revokes the old attempt and waits for that member to quiesce",为的就是 "preventing late results from overwriting the new attempt"(迟到的结果无法覆盖新的 attempt)。
另外队长这边被加了一道额外的规矩:队长想自己接管一个成员名下的任务时,不能直接 update_task,必须先走一次 reassign_task(assignee="captain")。工具描述里写得很清楚:
A captain must use reassign_task(assignee="captain") before updating member-owned work.
理由是同一个:"我接手"这个动作本身,也必须是一次正式的、会作废旧凭证的所有权变更,不能靠直接改状态绕过去。否则"队长正在写"和"旧成员以为自己在写"就会同时成立。
一句话总结这一节:它用一枚会过期的令牌,替代了本该有的分布式锁。 在这套系统里,谁在写不重要,谁手里攥着当前那次 attempt 的凭证才重要。
四、调度器:把"轮询"换成了"事件驱动"
Claude Code 的 teammates 是怎么拿活的?每轮结束之后,去轮询那张共享任务表。 也就是要有那么一个还活着的轮次,不停地问"有没有我能干的事"。
DSH 这边不太一样:它的可续聊代理会显式地抛出 idle / running 状态边。于是调度器的注释开门见山——
Claude Code teammates keep polling the shared task list after a turn. DSH continuable agents instead expose explicit idle/running edges, so this scheduler closes the same loop without keeping a polling turn alive: every idle edge and every task-graph mutation attempts one atomic claim and wakes the selected durable member.
翻译过来就是:它不需要靠一个还活着的轮次去轮询,而是监听事件。 每一次"某成员变空闲"和每一次"任务图发生变动",都会触发一次原子领取,然后唤醒被选中的那个持久化成员。没有常驻轮询,就没有那笔常驻的开销。
调度器监听的就是一条事件:
ctx.on('agent/status', ({ agent, status }) => { void syncMemberStatus(agent, status).catch((error: unknown) => { ctx.logger.warn(`agent-teams: member status scheduling failed for ${agent.id}: ${String(error)}`) }) })同步函数在成员真的 idle 之后回写状态,并顺手踢一次 kickMember。
但这里有个很现实的坑,也是让人觉得设计得最有意思的一处。一个成员在磁盘上可能处于"空闲",可它手上却还攥着一个 claimed / in_progress 的任务——这通常意味着它那一轮的活儿丢了:模型提前停了、中断结算得不对、进程重启了。天真的做法是把它当成"忙",然后这个任务就永远卡在那儿。插件选了另一条路:
An idle/ready member that still owns an open task lost the turn that was executing it (model stopped early, interrupt settlement, or process restart). Retry that task with a fresh capability instead of permanently treating the durable claim as "busy".
撤销旧凭证、开一代新的 attempt、把同一个成员重新唤醒。 与其相信那个"忙"的状态,不如相信事实。这一点和上一节的令牌机制是同一根骨头——用代次把"上一次执行"和"这一次执行"彻底切开。
派发的收尾也做得很克制。如果这次唤醒没被成员接受,它只回滚"自己那一次"派发——回滚前先比一遍 attemptId,只有还是自己刚发出去的那一枚才退回 pending;要是期间队长已经转派过,凭证对不上,那就让新状态赢,它自己安静退出。
Roll back only our exact failed dispatch. A concurrent captain handoff has already changed the capability and wins.
还有一个兜底值得记一笔:如果消息没法实时投递(成员那会儿不可达),它不会丢,而是持久留在邮箱里,等下一次状态边界重投。为了防止"实时投递"和"兜底投递"两条路径抢同一条消息,每条消息上有一个 60 秒的投递租约(deliveryClaimedAt),谁先租到谁投,投成功才真正标记已读。
五、通信:文件系统邮箱,成员可以绕过队长
团队里所有消息都写进对方的 JSONL 邮箱,追加式、原子写。一个成员想找另一个成员,直接用 agent_teams_send_message,消息直达对方邮箱并唤醒对方,不需要队长中转。
这跟 Claude Code 的 mailbox 模型是一致的。但插件在此之上还接了一条实时通道:队长在线的时候,成员发给队长的汇报不只是躺进邮箱,还会走 Agent.steer() 插到队长最近的一个模型步里——
Agent.steer()targets the next step while the captain is running, wakes a new turn when it is idle... This prevents reports from waiting behind the captain's entire orchestration turn.
不这样做的话,成员的报告得排队等队长把整轮编排跑完才能被看到。而给成员发消息,用的是 followup(),直接把内容变成成员的下一轮。
有一处很小的约束,特别关键,消息里有 from 字段,而代码只允许它等于调用者自己的身份:
frommay only be the caller's own identity: impersonating another member (or the captain) would poison the mailbox and event records.
不允许冒名。 在一个成员之间可以自由发消息的系统里,这是必须堵死的口子——否则谁都能以别人的名义往邮箱里塞话,整套审计记录当场作废。
消息对象上还有一整套生命周期字段:deliveryClaimedAt(投递租约)、deliveredAt(已被实时收件箱接收)、readAt(已被消费)。一条消息的状态是能被一步步追出来的。
六、边界:成员能做什么,不能做什么
前面几节讲的全是"怎么协作",这一节讲"怎么不让它乱来"。
成员的运行形态是队长的可续聊子代理(continuable subagent)——它的会话跨轮次、跨进程重启都还在。创建时它会收到一份自己的 persona(替换掉部署的默认人格),里面第一句话就交代了身份,后面跟着七条工作规则。摘最后两条:
6. After your turn becomes idle, the shared task scheduler may assign your next ready task automatically. Never claim a second task while you still own unfinished work.<br/>7. You are a worker: do not create or delete teams, reassign tasks, or add/remove members — that is the captain's job.
这两条各自对应代码里的一处硬约束。第 6 条对应的是:一个成员不能同时拥有两个未完成任务——领取和转派前都会查一遍,撞上了直接报错。第 7 条对应一份工具黑名单——建队、加人、移除、转派、建任务、删队,这六个队长专属工具,对成员直接隐藏:
const MEMBER_DENIED_TOOLS = [ 'agent_teams_create', 'agent_teams_add_member', 'agent_teams_remove_member', 'agent_teams_reassign_task', 'agent_teams_create_task', 'agent_teams_delete', ] as const顺带一提,这份黑名单比 persona 里那条规矩还多禁了一个 agent_teams_create_task——提示词只说了别建队、别转派,代码干脆把"建任务"也一并收走。再加上配置里的两个默认值(maxMembers: 8 卡住团队规模,memberMaxDepth: 1 只允许成员再往下派一层),成员的权力边界是被一层层收窄出来的。
对照前几篇读过的 Claude Code:Teammate 是"平级同事",能互相通信、有独立身份;coordinator 底下的 worker 是"外包",工具被收窄到只剩干活。这个插件其实是个混合体——成员之间可以像 Teammate 那样直接对话,但工具面被收得像 worker 一样窄。 它要的是"沟通平级化、权限不平级"。
成员用什么模型,也有个很好的产品决策:默认零交互。插件会快照队长当前这一步真正在用的 provider、model 和思考强度,成员后续接着用这份快照。只有用户明确说"后端用 A 模型、前端用 B 模型"这种异构需求时,才会把 provider + model 传下去。绝不会挨个弹窗问你"这个成员用什么模型"。这种"能不问就不问"的克制,在工程里其实是稀缺品。
七、面板:它宁愿相信磁盘,也不相信模型
活动面板是浏览器那一半。数据由 host 一条路由提供:
/plugins/dsh-agent-teams/state这条路由每次都重新读磁盘上的团队文件,再把实时的子代理活动合并进来。为什么非要这么做?注释里给的理由很实在:
...so the panel always reflects the on-disk state even when a model skipped a tool "ritual" (e.g. not calling update_task on completion).
模型偶尔会忘记走工具"仪式"——活干完了,却没调 update_task 把状态改过来。如果面板相信模型的汇报,它就会撒谎;如果面板相信磁盘,它就永远是实话。所以这条路线的设计原则是:真相源只有一个,就是磁盘。
面板本身是个右上角的浮层(挂 body portal),1 秒轮询一次;展示队长的分段总进度、可折叠的成员树,还有一张用依赖深度分列的紧凑任务 DAG,边是 SVG 三次曲线画的,悬停或键盘聚焦能高亮一条完整的上游-下游链。头像用的是 DeepSeek 小鲸鱼的职业插画,按角色关键词(中英双语正则)匹配——researcher、engineer、qa、security……状态小图还会跟着"工作/空闲/未知"切换,并且老老实实遵循 prefers-reduced-motion。
最后一个细节,回到了团队的生命周期终点。agent_teams_delete 这个工具名字叫"删除",但它实际做的是归档:
Archive, not delete: tasks (with their dependency graph) and the mailboxes stay on disk for later review and dependency rebuilds.
整个团队目录被移进 archive/<teamId>/,任务、依赖图、邮箱全套留档,以后再开会话还能把同一张成员树和 DAG 恢复出来。面板那条路由也留了开关:请求带上 ?archived=1 就改读 archive/ 下的历史团队,而且特意保留已标记 removed 的成员——不然恢复出来的树会缺人。对照 Claude Code 那边"团队文件以前不删会永远留在磁盘上"的教训(那个还挂着 issue 号 gh-32730),这里的答案不是"记得清理",而是"把清理换成归档"——从源头让这件事不再是问题。
写在最后
回头看,这个插件最打动我的不是那个活动面板,也不是小鲸鱼头像,而是三件很"土"的事:
- 调度不轮询,改写事件。
一句注释直接承认了它在这一点上和 Claude Code 走了不同的路——不是抄,是看到接缝不一样之后的重新选择。 - 用一枚会过期的 attempt 令牌,替代分布式锁。
全部并发问题的答案,收敛成"谁攥着当前凭证,谁才说了算"。 - 文件名折叠的注释里,写着一整段踩坑史。
中文名成员被静默合并这种事,只有真跑过、真痛过的人才会留下这样一段话。
前几篇读的是 Claude Code 官方怎么设计多智能体;这篇读的是一个开发者怎么把它落地成一个插件。两边的差距恰好说明了一件事:概念框架谁都能画,真正决定成败的,是"迟到写入不许覆盖新结果""成员空闲了但活丢了怎么办""消息两路投递打架怎么算"这些没人愿意在架构图里画出来的细节。
参考资料
[1] dsh-agent-teams 源码
[2] dsh-agent-teams 官方文档