安装
在 DeepSeek Harness 里通过 dsh-market 安装
dsh plugin --profile web add dshmarket
或使用命令行
dsh plugin --profile web add github:shenhuanageshei/dsh-team-link
装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络。请先审阅源码,并尽量锁定 commit(github:owner/repo#sha)。
README
DeepSeek Harness (DSH) 的「多会话协作」插件 —— 让同一个 DSH 实例里并行干活的多个会话互相看得见、说得上话、交接得了班。
原名
dsh-session-link-pro(0.2.4 及之前),GitHub 仓库已于 2026-09-18 改名为dsh-team-link(旧地址由 GitHub 自动重定向)。历史会话日志里的旧工具名session_link_pro_*与消息 id 前缀slp-保持原样——它们是取证链,不做回写。
Fork 自 PwnKY/dsh-session-link——深链复制、/s/<id> 打开器与深链上下文注入保留自上游;本仓库在其上长出了完整的多会话协作层。
与 DSH Agent Teams 的关系:正交,不是竞争
DSH 0.1.7-rc 系列起随包提供实验性的 Agent Teams(@deepseek-ai/dsh-experimental-agent-team-profile,默认关闭、需在插件页显式开启):一个会话可以创建具名 teammate(子代理)、与它们交换持久消息、用一张共享任务板协调工作,并在 Web 面板里看成员与任务。它和本插件解决的是两件不同的事:
| 内置 Agent Teams | dsh-team-link(本插件) | |
|---|---|---|
| 团队是什么 | 一个会话内部的团队:Lead 会话 + 它生出来的子代理,单根树 | 会话之间的团队:几个平级、各自独立生死的会话 |
| 身份与家谱存在哪 | Lead 会话的日志里(TeamId = Lead 的 SessionId) |
插件 settings 的 teams 键,不挂在任何会话身上 |
| 进程边界 | 单进程 —— 官方明确「多个进程需要协调同一支团队」不在支持范围 | 跨会话、跨进程 |
| 信任模型 | 不需要:子代理的权限由 Lead 派生 | 必须有:双门批准 · 配对 · 一次性令牌换届 · 退役者对称吊销 |
| 人在环 | Lead 即授权者 | 唯一的新授权点是人类点击(fail-closed) |
| 换人 / 换届 | 没有这个概念:持久状态要有人重开 Lead 会话才回放得动 | revive / reappoint 两个封闭动词 + 令牌换届 + 信任迁移 |
| 工作跟踪 | 有共享任务板(CAS revision · 依赖 · 写作用域) | 只追加的任务台账 tasks.md(记「谁声称了什么」,读面的「最后主张 / 未消解存疑」是派生读数、绝不自动裁决;§9 第一批已实施) |
| 稳定性 | 实验原型,不承诺稳定性,schema 可自由变更 | 1324 + 310 条断言 + 变异验证(红相必红:DEFECT-5(2026-09-30,revive 的 resume 面漏了宿主自己那条 resume 的三样)10 条新判据 + 1 条改写打在本轮修复前的实现上实测红 11 条(聚合红相 1324 (failed: 11));形态批 41 条新判据打在实施前的实现上实测红 33 条;分歧修复轮 DIVERGENCE(8) 的 16 条新判据(+ 3 条改写)打在本轮修复前的实现上实测红 13 条(首跑打印的 14 条里有一条是夹具假红,已归因;另 6 条为覆盖缺口类、本就绿,如实标负相);收尾修复轮(R1–R6)的 9 条新判据(+ 1 条改写)打在本轮修复前的实现上实测红 8 条(另 2 条为覆盖缺口类 / 反向锁,如实标 ★ 负相);真机冒烟修复轮(L1 时基 / L2 镜像)的 10 条新判据(+ 10 条改写)打在本轮修复前的实现上实测红 16 条(聚合红相 1203 (failed: 16) = 6 条新判据 + 10 条改写的期望值;另 4 条新判据为覆盖缺口类,本就绿,如实标 ★ 负相)—— 策略持久化自持化一轮(2026-09-27)新增 34 条宿主断言(1237 = 1203 + 34):文件后端档的 11 条打在实施前的实现上,其中 7 条实测红(聚合红相 1214 (failed: 7),含 1 条 import 白名单锁)+ 4 条覆盖缺口类 ★ 负相(本就绿,如实标);折叠/可核/迁移档的 23 条问题先于实现写出,其区分性改用变异验证(8 次单点变异逐条证明新判据真会变红,见该节);分歧审计修复轮(DIVERGENCE(10),2026-09-27)新增 7 条宿主断言(1244 = 1237 + 7),打在本轮修复前的实现上实测红 6 条(聚合红相 1244 (failed: 6)),另 1 条为覆盖缺口类 ★ 负相);代码评审修复轮(2026-09-27,2 🟡 + 4 🔵)新增 11 条宿主断言(1255 = 1244 + 11):U11 损坏档守卫 4 条、U12 写失败 warn 按段复位 2 条、foldedAt 盖戳门 2 条、refused 未挂载窗口 3 条,打在本轮修复前的实现上实测红 8 条(聚合红相 1255 (failed: 8)),另 3 条为控制/对照项 ★ 负相;代码评审第 2 轮(2026-09-27,1 🟡 + 2 🔵)新增 7 条宿主断言(1262 = 1255 + 7):🟡#1 catch 支盖戳门 + 🔵#2 数组 policy 也判损坏 + 🔵#3 两因并存并陈,打在本轮修复前的实现上实测红 4 条(聚合红相 1262 (failed: 4)),另 3 条为控制项 ★ 负相;代码评审第 3 轮(2026-09-27,2 🟡 + 1 条文档滞后)新增 2 条宿主断言(1264 = 1262 + 2):🟡#1 fold failed 后链尾不回写 + 🟡#2 detach() 同步置窗口判据,打在本轮修复前的实现上实测红 2 条(聚合红相 1264 (failed: 2),控制条件并入同条断言);C 批分歧审计修复轮(2026-09-27,🔵#2/#3/#4/#5)新增 1 条宿主断言(1300 = 1299 + 1):🔵#5 的 null 判据,打在本轮修复前的实现上实测红 1 条(聚合红相 1300 (failed: 1))—— 该条的红是判据面 + 「行为修正」标记缺席(null 一档的行为在修复前已成立,如实标 ★ 负相、不冒充红相);C 批代码评审修复轮(2026-09-27,2 🟡 + 4 🔵)新增 12 条断言(host 6 + client 6,1306 = 1300 + 6 / 305 = 299 + 6):面板类名↔规则 2 · 翻页不回卷 3 · 降级句随页 1 · 派生行归属 2 · wellFormed 2 · key 完备 2,打在本轮修复前的实现上实测红 9 条(聚合红相 host 1306 (failed: 5) / client 305 (failed: 4)),另 2 条为控制/对照项 ★ 负相(本就绿)、1 条为实施后补强且未取得红相读数(如实标注,不冒充)) |
我们不可被替代的四件事:跨进程 / 跨团队 root 的协调 · 平级会话之间的信任(双门、配对、对称吊销)· 身份的生死与任何一个会话解耦(换届换人、退役留痕、家谱可查)· 人在环的授权点。内置那套更擅长的则是同一间办公室里的快速分工(子代理随叫随到、任务板可改状态、状态变更零 token)。
两者结构上正交,不存在二选一:内置的成员(子代理)不在本插件的投递面上(本插件的投递面只列根代理),所以按场景选就行 —— 要跨会话、要信任、要交接 → 用本插件;要在一个会话里快速并行 → 用内置那套。
与常规 agent team 框架(CrewAI / AutoGen 一类;一般性描述,未逐版本核实)的差别同源:那些框架把多个角色放进同一个进程、同一个编排器里,靠函数调用或共享 memory 传消息 —— 没有独立会话、没有跨进程信任问题,团队随主程序生死。
14 项逐项对照、三条结构性差异与逐条出处见 docs/comparison-agent-teams-2026-09-26.md。
目录
- 与 DSH Agent Teams 的关系:正交,不是竞争
- 实战效果:两个真实项目的长跑记录
- 这是什么:三个痛点
- 架构
- 快速开始
- 工具一览
- 一、跨会话消息
- 二、会话可视:列表 / 深链 / 导出
- 三、跨会话看门狗
- 团队状态卡(只读)
- 团队形态(多会话档 ↔ agent-team 档)
- 四、团队:roster 与黑板
- 五、团队换届 rotation
- 六、策略配置
- 七、服务获取与留痕(0.3.7 的关键修复)
- 八、兼容性与字符串安全
- 九、安装
- 十、测试
- 设计文档索引
- Changelog
- Credits · License
实战效果:两个真实项目的长跑记录
下面两个案例来自连续多天、经历多次换届的真实多会话项目,全程由本插件的协调会话(团队里的「主管」)驱动,工作线是人类可见的独立会话。本项目的部分机制即来自跨会话协作时模型的自主涌现。
案例一|命令行工具项目:换届 + 夜班自主推进
| 过程指标 | 读数 |
|---|---|
| 团队规模 | 1 名协调者 + 6 条工作线并行(其中 2 条中途失联,由 roster 的活性诊断发现) |
| 角色交接 | 主管角色经历多次换届;本窗口内完成一次完整 claim(一次性令牌 + 信任快照 + rotation-freeze 广播 + 版本史留痕) |
| 跨会话记忆 | 黑板裁定从移交时的 139 条增长到 216 条以上;换届后由新会话无缝续写,磁盘文件零丢失 |
| 一夜战果(人类睡眠 + 显式授权自主推进) | 12 个批次全链闭环,全部代签合并并同步双远端——该项目的既定门是「分歧审计 → 代码评审 → 实测」三道 |
| 人类介入 | 夜间零介入;醒后只做「批准 / 否决」类裁决 |
它证明了什么:主管这个角色是可以换届的。上下文疲劳、会话被重启带走、人要去睡觉——都不会让一条长任务停下。接手靠的是五硬节交接文档 + 黑板 + 一次性令牌,而不是「谁记得」。
案例二|数据平台项目:四线并行 + 互相核对抓错
| 过程指标 | 读数 |
|---|---|
| 团队规模 | 1 名协调者 + 4 个 worker 会话同时在线 + 若干后台实施作业 |
| 单日产出 | 主干 13 波推进、黑板 20 条裁定 |
| 纪律条款 | 从移交时的 14 条增长到 19 条以上——来源全部是真实事故:占位符三犯 · 平面连错 · 冻结前没真跑 · 读数转述 · 缓存版本戳漏 bump…… |
| 角色交接 | 一次完整换届:一次性令牌在 30 分钟窗口内完成认领;迁移前快照 8 组配对关系 |
| 会诊 | 两轮:第一轮 3/3 跑题(如实留档),改用严约束简报后命中并产出采纳条件 |
互相核对抓到了什么——这一节是全文最不可替代的部分:
| 谁 → 谁 | 抓到什么 | 后果 |
|---|---|---|
| 工作线 → 协调者 | 协调者漏推了一笔提交(四重独立证据) | 补推;协调者违纪入档,该纪律被再次强调 |
| 工作线 → 协调者 | 协调者的事实性误判(某张表与某条约束是否存在) | 认错并入档,避免了按错误前提实施 |
| 工作线 → 协调者 | 协调者提的一条修复方案其实是静默空操作(两组独立证据) | 方案当场撤回,没进生产 |
| 协调者 → 工作线 | 工作线虚报了一个后台作业「已派发」 | 查作业表发现不存在,重新派发 |
| 另一条线 → 设计稿 | 设计稿断言「零 DDL」被引文推翻(约束真实存在) | 打回修订——按原稿实施会在生产写库时直接撞约束 |
| 三个不同会话 → 量具 | 哈希口径族不可比 · 行尾 CRLF/LF 假差异 · 台账断档(三次独立拦截) | 三条口径纪律入库 |
为什么这很重要——要点在两件常被误解的事上:
- 不是同一个模型自查,而是不同模型、不同供应商的会话互相抓错。 这些席位上坐过不同家族的模型,而且每个会话可以单独指定 provider / 模型 / 思考强度,也能中途换模型——身份挂在会话上,不挂在模型上。所以「互相核对」是真的互相(不同训练来源、不同盲区),而不是同一个脑子换个座位。
- 做成这些事的主力是 flash 级执行模型,不是顶级模型。 案例里多条工作线全程用 flash 级模型跑(便宜、快,才敢同时开多条),强模型只花在两处:首轮设计评审与关键决策会诊。质量由组织 + 纪律兜底,而不是「每个环节都用强模型」。
上面六例没有一件是靠「更聪明的模型」挡住的,全靠结构:独立上下文 ⇒ 天然各自核验;裁决与纪律落盘 ⇒ 记忆不靠上下文;跨线质询 ⇒ 没有「自己批自己」。这正是本插件那条设计红线(报告必须原话点对点推;共享状态只能是「主张」,不能是「事实」)的实测回报:把报告换成「写张便条贴墙上、让主管自己看」,这六例中的每一例都会静默通过。
这两个案例比「一个会话里的 agent team」多出了什么
| 维度 | 多会话编队(本插件 + 工程模式) | 会话内 agent team |
|---|---|---|
| 核验 | 每线各自核验,且跨线互相质询——A 的结论被 B 的实测打回 | 自报结果,父会话只看到摘要 |
| 上下文 | 按角色切分:worker 只拿自己那一段,便宜且少污染 | 继承父上下文,容易互相污染 |
| 续航 | 黑板 + 看门狗 + 换届 + 恢复:角色可在会话之间移交,几十小时不断线 | 随父回合成败起落 |
| 审计 | 决策与纪律落盘可导出,事后能复查整条协作过程 | 只留在对话流里 |
| 覆盖 | 跨任意会话:别的团队、别的项目、人开的会话 | 限于本队内部 |
| 人的位置 | 人可直接进任一会话旁听 / 介入;工具层可读全队活性 | 人是调度者,队友是进程内编制 |
避免过度声称:「队友要持久」不是差别——agent team 本身就是持久队友。差别在载体:会话是盘上、可被人类直接进入、可被移交的实体;teammate 是进程内的编制。所以「跨任意会话」与「换届 / 恢复」是它结构上够不到的,其余几项是程度的差别。与内置 Agent Teams 的逐项事实对照见 上一节;这里是实测多出来的东西。
更早一批被挡下的真实事故(同一种打法,是记录不是推演)
| 事故 | 被什么挡下 | 挡住的是什么 |
|---|---|---|
| 跨会话 id 凭记忆转录、差一位 | 工具**如实报「没有活动代理」**而不是静默丢消息 → 定位到是转录错误 → 立规:发送前先 list_sessions 复制 id |
消息静默丢失 / 误判对方失联 |
| 整份重写把一个 1588 行的文件截成 1240 行 | 取证证明损坏文件是 HEAD 的严格前缀 → 回滚 + 11 处定向编辑,净损失 0;立规「行数守恒 + 结构性改动只用编辑工具」 | 静默的文件损坏被当成正常产出 |
| 用错量具:按 UTF-16 数行数(1588 数成 1435) | 差点误报「多了 150 行外来代码」→ 立规:行数只认 splitlines / git numstat |
把工具假象当成代码事实 |
| 两条分支上的修复发散 | 动手前发现另一侧也有一处修复,常规合并会静默回退它 → 改按 blob 逐文件取字节 + 每文件回执 | 一次没人会发现的反向回退 |
| 一处修复引入反向回归(环境变量能把结果压得比修复前更差) | 变更前实测发现 → 冻结常量 | 修复本身变成新缺陷 |
| 一条工作线提议「把检查门全放」 | 它自己发现那会连篡改 / 伪造 / 泄露检查一起放掉(27 个完整性用例会一起翻绿)→ 撤回方案,改从根上消除 | 为了「能出结果」把安全底线一起拆掉 |
| 首次发布 4/4 全失败 | 没有急着归因到新批次:终局回放 6/6 证明是既有的「首败正常」模式 | 把既有问题误判成自己引入的,然后瞎修 |
| 发布的「成功」报告其实是空壳 | 亲读发布物发现零声明 → 纠正为「判据语义太窄」而不是「接线遗漏」 | 把「没报错」当成「合格」 |
| 一次会诊的四份回复全在答元问题 | 判 0 可用 → 换成「编号作答」的严格要求,才拿到材料级回复 | 把「模型说没问题」当成确实没问题 |
| 协调者打断了工作线的正常长回合 | 如实记「干预早了些但无害」→ 据此改对心跳与长等待协议 | 把「静默」一律当成「卡死」 |
上面这十例,没有一件是靠「更聪明的模型」挡住的,全靠结构:独立上下文 ⇒ 天然各自核验;裁决与纪律落盘 ⇒ 记忆不靠上下文;跨线质询 ⇒ 没有「自己批自己」;换届 + 恢复 ⇒ 角色能在会话之间移交。那一轮结束时两条工作线的上下文已经耗尽,而工作没丢——交接文档 + 黑板 + 主管接手,收尾项 45/45 落完。
叠加工程模式(dsh-thincoder-suite)才是完整形态
本插件负责跨会话那一半:身份、投递、信任、交接、记忆、看门狗。另一半——单会话内的审级与实施门禁——由同厂的 dsh-thincoder-suite(工程模式)提供。两个案例里的「全链」都是这两半叠出来的:
| 环节 | 提供方 |
|---|---|
设计评审(advisor type=design)→ 签发一次性设计令牌 |
工程模式 |
实施只能由 eng_coder 带令牌执行(无令牌物理拒工) |
工程模式 |
分歧审计(只读子代理)→ 代码评审(advisor type=code) |
工程模式 |
| 真跑门 / dry-run / 变异验证 / 推送后 readback | 两者共同——纪律由本插件落进黑板,工具由工程模式提供 |
| 跨会话派工 · 汇报 · 裁决回执 · 换届 · 记忆 · 失联巡检 | 本插件 |
一个可复述的判据:案例里每个批次都要走 「设计 → 评审 → 令牌 → 实施 → 审计 → 代码评审 → 合并 → 部署」八道门——前六道是工程模式的门,跨会话的派工、回执、裁决与换届是本插件的门。
如实标注:实测到的边界
| 边界 | 实测 | 已固化的应对 |
|---|---|---|
| DSH 重启会清掉本插件的团队注册表(settings 索引) | 多次实测;黑板磁盘文件本身完好,丢的是索引 | 纪律「重启后先验 roster 再写」;重建走「创建即认领」 |
| 工作线会话被重启带走后无法投递 | 多次实测(插件只标 dead,不会凭空唤醒) |
需人在侧边栏重开一次该标签页;看门狗把 dead 报给协调者 |
| 个别部署上消费方工具读不到正文 | 实战中反复出现 | 走落盘文件 + 会话状态文件两条替代读取路径 |
| 会诊(多模型并行)会跑题、也会越界写文件 | 实测:一轮 3/3 跑题;一次只读契约被违反(子进程改了仓库文件) | 严约束简报 + 只读面外部约束;违规写入按「对不对 ≠ 有没有权做」处置 |
| 跨会话投递面只覆盖根代理 | 设计如此(子代理不是可投递目标) | 需要在拒绝时说清原因并指路——见 对比页 §6 的候选改进 |
这是什么:三个痛点
在一个 DSH 实例里开多个会话干活(一个协调者 + 若干 worker)时,会话之间默认是隔离的:
| 痛点 | 表现 |
|---|---|
| 看不见 | 不知道别的会话在干嘛:跑着还是卡了?在等人还是已经静默十几分钟? |
| 说不上话 | 没法把 A 会话的结论交给 B 会话继续;想归档一个会话只能翻 UI |
| 交不了班 | 会话上下文会疲劳,但把协调者换成另一个会话,意味着信任关系、团队身份全要人工重来 |
本插件补上这三层能力:
| 能力 | 说明 | 入口 |
|---|---|---|
| 🔗 会话深链 | 复制 dsh://session/<id>,粘贴到任意会话即注入该会话只读快照(上游功能) |
会话头部按钮 / 粘贴链接 |
| 📋 会话列表 | 列出同工作区其他会话:主题、运行状态、最近消息摘要,以及活性信号行(verdict 五态 + goal 状态 + 静默时长 + 读数时效戳) | team_link_list_sessions |
| ⬇ 会话导出 | 全量事件导出为 markdown(可读)+ JSON(无损) | team_link_export / 会话头部 ⬇ 按钮 |
| 📨 跨会话消息 | 投递给另一会话,空闲目标自动唤醒为新回合;返回带 busy 预判;目标没活动代理时以 ❌ 未投递 开头并列出同工作区存活会话 |
team_link_send |
| 📣 广播 fan-out | 一次投多个目标:会话 id / team:<name>/<role> / team:<name>/*(全队,仅现任协调者);任一目标未投递则返回首行即 ❌ N 个目标未投递 |
team_link_send 的 targets |
| 🏷 信封 banner | 可选 meta(type / pri / ref)渲染进投递 banner 首行,审计一眼看清消息性质 |
team_link_send 的 meta |
| 🔁 配对通道 | 双方各批准一次后,两个会话互发免确认 | 接收确认时选「配对」 |
| 🐕 跨会话看门狗 | 盯住别的会话:它失联而我空闲时,插件向我自己的会话投一条 tick | team_link_watch |
| 🎭 团队 roster | 团队 → 角色 → 会话的身份注册表,含版本史(退役≠删除)与写入策略 | team_link_roster |
| 📋 团队黑板 | decisions.md(只追加裁决账本)+ discipline.md(整文件替换,乐观锁)+ tasks.md(只追加任务台账) |
team_link_team_read / team_link_team_append |
| 🔄 团队换届 | 两阶段交接:一次性令牌 + 域限定信任迁移 + 退役者对称吊销 + 24h 可回退 | team_link_rotate |
| 🚑 团队恢复 | 现任「有席位但无活代理」时的窄恢复路径:恰两个封闭动词(revive / reappoint),人在环 fail-closed(§11.9.4) | team_link_recover |
一条设计红线:绝不把「没投出去」说成「已发送」
这个插件里所有对外文案都遵循同一条纪律——拒绝要刺眼、降级要留痕、不确定要如实标注。三条具体体现(都是生产事故换来的):
- 目标没有活动代理 → 首前缀是
❌ 未投递,并列出同工作区其他存活会话(有人真把「1 投递 / 2 拒绝」读成「已广播」); - 广播只要有一个目标没到 → 首行就是
❌ N 个目标未投递(M 个已投递),逐目标明细排在后面; - 服务取不到时必须留一行 warn,绝不静默降级(见第七节——0.3.7 修的就是一次「静默降级了整整一天没人发现」)。
架构
插件是标准的 DSH bundle 插件,分宿主半边与浏览器半边:
flowchart TB
subgraph Shell["DSH shell(web profile)"]
direction TB
subgraph Host["宿主半边 · lib/index.js"]
T["10 个工具 + 2 条 / 命令<br/>list / export / send / watch<br/>roster / team_read / team_append / rotate / recover<br/>status(只读状态卡)<br/>/team_session · /team_rotate"]
R["HTTP 读路由<br/>GET /team-link/export<br/>GET /team-link/panel(只读面板)<br/>同一道 connection 信任栅栏"]
W["看门狗巡逻定时器<br/>+ 换届到期清扫"]
DL["深链解析(上游功能)<br/>dsh://session/<id>"]
ST["policy store<br/>settings 命名空间 team-link<br/>(settings 缺席时 → 插件自己的 policy.json)"]
end
subgraph Client["浏览器半边 · lib/client.js"]
CARD["📡 消息卡片<br/>chat.node keyed slot"]
BTN["会话头部按钮<br/>复制深链 / 导出"]
SLOT["侧栏「会话工具」入口<br/>sidebar.footer.action<br/>任意会话:复制 / 导出 / 打开<br/>+ 团队面板分区(只读)"]
end
end
S1["会话 A(协调者)"] -->|team_link_send| T
S2["会话 B(worker)"] -->|team_link_*| T
T -->|steer / followup| S2
T --> ST
ST -->|持久化| YAML[("profile/settings.yaml<br/>team-link: …")]
ST -->|settings 缺席时的落点| PFILE[("<DSH_HOME 或 ~/.dsh>/team-link/<br/>policy.json")]
T -->|best-effort 镜像| BB[("<workspace>/team/<name>/<br/>roster.md · decisions.md · discipline.md · tasks.md")]
R --> CARD
BTN --> R
SLOT -->|导航 /team-link/export| R
要点:
- 宿主半边拥有全部工具、
/team-link/export下载路由、看门狗巡逻定时器与换届到期清扫;所有模型可见输出都过wellFormed()(字符串安全,见第八节)。 - 浏览器半边只做两件事:把跨会话消息渲染成醒目卡片(影子替换 chat 包默认的灰字行,非本插件消息委托回原渲染器),以及会话头部的「复制深链 / 导出」按钮。
- 状态的事实源先 settings 后文件:settings(命名空间
team-link)在场时它是事实源;不在场时同一份状态写在<DSH_HOME 或 ~/.dsh>/team-link/policy.json。一个时刻只写一处(不双写),<workspace>/team/<name>/*仍只是人可读镜像,写入失败只告警、不回滚事实源。落点可核:team_link_roster action=get与team_link_status的首行永远说得出此刻状态存在哪。 - 团队身份与信任关系(pairs)分开存:roster 管「谁是谁」,pairs 管「谁能免确认发给谁」——换届时两者都要动,但动作路径不同(见第五节)。
快速开始
场景:一个协调者会话 + 一个 worker 会话,同一个工作区目录。
sequenceDiagram
autonumber
participant U as 用户
participant A as 会话 A(协调者)
participant P as 插件
participant B as 会话 B(worker)
U->>A: 「建团队 night,我当协调者」
A->>P: team_link_roster action=upsert-team
Note over P: 创建路径把 A 播种为<br/>coordinator 现任(创建即认领)
P-->>A: 已创建;coordinator 已由 A 认领
U->>A: 「把 B 注册成 worker」
A->>P: set-role(role=worker, session=B)
P-->>A: 已设置(写权限门通过:A 就是现任)
U->>A: 「广播全队:报到」
A->>P: team_link_send targets=["team:night/*"]
P->>B: 📨 跨会话消息(steer/followup)
B-->>U: 收到卡片并响应
四步上手:
- 开两个会话(同一个工作区目录)。协调者用强模型,worker 用
flash即可;worker 的开场白一句话:「你是 worker,等主管派活」。 - 建团队(在协调者里说):「创建团队
night,我当协调者,把 B 注册为 worker」。创建即认领,不需要手改任何配置。 - 建信任通道(可选,但推荐):让 A 给 B 发一条消息 → 接收侧确认框里选「配对:双向免确认」→ 此后 A↔B 互发免确认。
- 派活与记账:「广播全队:……」/「这条记入裁决账本」/「更新纪律条款」。
常用话术速查:
| 想做什么 | 对模型说 | 背后工具 |
|---|---|---|
| 看团队与活性 | 「现在团队状态如何 / 列一下其他会话」 | team_link_list_sessions |
| 看团队一屏现状 | 「出一张团队状态卡」(可加「只看 night」) | team_link_status(六段+⑦形态;全只读) |
| 读到第 13 行 / 点名读某人 | 「列会话,从第 13 行起读」/「点名读 <会话 id> 的活性」 | team_link_list_sessions 的 offset / readIds(单次 ≤12 次面读) |
| 广播 | 「广播全队:<内容>」(仅现任协调者) | send + targets=["team:<n>/*"] |
| 点对点 | 「发给 worker1:<内容>」 | send + targets=["team:<n>/worker1"] |
| 按优先级/性质发 | 「以 P0 裁决回复 W1,引用我上条消息」 | send 的 meta 信封 |
| 记账 | 「这条记入裁决账本」 | team_link_team_append(decisions) |
| 改纪律 | 「更新纪律条款」 | team_link_team_append(discipline,带 baseHash) |
| 派活落账 | 「派给 W1 复核 §3 的行号,记一笔」 | team_link_team_append(tasks + kind=plan,返回 t-<n>) |
| 回报状态 | 「记一笔:t-7 我接了 / 声称完成 / 卡住了 / 存疑」 | team_link_team_append(tasks + kind=claim/done/block/dispute + task=t-7) |
| 看某个任务回过什么 | 「t-7 收到过谁的回报?我发出去之后对方动了没有?」 | team_link_team_read 的收件视图 + 派生回执(默认只读自己的会话面) |
| 盯人 | 「盯住 W1/W2,静默 15 分钟叫我」 | team_link_watch |
| 交班 | 「我下岗,让 session-X 接任」 | team_link_rotate(两阶段) |
| 切形态(多会话 ↔ agent-team) | 「把团队切到 agent-team 档」/「切回多会话档」 | team_link_roster action=set-mode(双重门:writer gate + 人类确认框;缺一即零写入) |
| 归档 | 「导出这个会话」 | team_link_export / 头部 ⬇ |
工具一览
| 工具 | 一句话 |
|---|---|
team_link_list_sessions |
同工作区其他会话 + 活性信号行(verdict 五态 / goal / 静默时长 / 读数时效戳);默认读前 12 行的活性,要读更多用 readIds(点名)或 offset(分页)——单次调用 ≤ 12 次 surface 读,末尾给「未读 Y 行 + 怎么读」 |
team_link_export |
任意会话全量导出 md + JSON |
team_link_send |
跨会话投递(单目标或 targets 广播 ≤8);meta 信封;返回带 busy 预判;发送方自己那一行渲染成卡片(§10.1 A/D) |
team_link_watch |
给自己注册跨会话看门狗(register / list / clear) |
team_link_status |
只读团队状态卡:一次一屏(角色在位/空缺 · 换届 pending(token 掩码)· 看门狗 · 会话面 · 活性(有界 12 行)· 台账尾 · 形态(⑦,B 批追加))+ 反面预警注记;零写入、零额外读 |
team_link_roster |
团队身份注册表(get / upsert-team / set-role / retire / set-mode):get 附形态段(形态 + Lead + 成员名册投影 + 完整能力矩阵 + 诊断行),set-mode 切团队形态(见团队形态) |
team_link_team_read |
一次读齐 roster + decisions 末 20 条 + discipline 全文 + tasks 末 20 条与派生视图 + 收件视图(谁对 t-<n> 说过什么)与派生回执(我发出去之后对方动了没有:✅ / ⚠ / 未读)+ 三个 baseHash;目标面只在 readIds 点名时才读 |
team_link_team_append |
写黑板三件套:decisions 只追加 / discipline 整文件替换(乐观锁)/ tasks 只追加(kind 闭集,plan 分配 t-<n>,kind/task 误用一律拒绝) |
team_link_rotate |
两阶段换届(prepare / claim),一次性令牌 + 域限定迁移;successor:"auto" = 插件自建继任者 + 写交接文档 + followup 投递(§11.2) |
team_link_recover |
角色恢复(恰两个封闭动词):revive(复活当前现任那个会话本身,仅插件自建会话、任意角色,身份/信任零改动)/reappoint(人改任 = 人类对话授权的 prepare,候选 = 本队活成员 ∪ 常驻的「自建继任者(新建会话)」,由插件算出)。attended-only:无确认服务即 fail-closed,刻意没有无人值守变体(§11.9.4 / §4.2) |
team_link_export走sessionQuery读会话;team_link_send走agents投递。两者都不需要目标会话正在被 UI 打开——但目标必须有活动代理(见第一节的 A4 诚实声明)。
一、跨会话消息
投递语义:steer 还是 followup
flowchart TD
START["team_link_send"] --> Q1{"目标是同一工作区的<br/>存活根代理?"}
Q1 -->|否| NOAGENT["❌ 未投递<br/>+ 列出同工作区存活会话<br/>+ 三条核对提示"]
Q1 -->|是| Q2{"目标当前状态?"}
Q2 -->|运行中| STEER["steer<br/>在步边界注入当前回合<br/>返回追加「已运行 N 分钟」"]
Q2 -->|空闲| FOLLOW["followup<br/>唤醒为新回合<br/>消息立即可见并触发响应"]
目标运行中 →
steer:在步边界注入其当前回合;目标空闲 →
followup:唤醒目标会话,作为新回合处理(立即显示并触发 LLM 响应,不会静默排队);目标没有活动代理 → 拒绝,且拒绝文案自带自愈线索:
- 首前缀
❌ 未投递(不许被读成「已发送」); - 保留原句「目标会话
<id>没有活动代理」; - 新增同工作区存活会话列表:每行
id(运行中/空闲),上限 10 个,超出时注明「共 N 个,仅列前 10 个」; - 新增核对提示:对照 id(最常见成因是转录错位)/ 刚重启 DSH 时在侧边栏打开目标会话一次使其恢复为活动代理 / 先调
team_link_list_sessions(默认只读前 12 行的活性;要读窗口外的行,用新参数readIds点名读或offset分页——两者可选、互斥,单次调用 surface 读数仍 ≤ 12,展开见 §二)。
该列表是纯 agent 注册表读取(
ctx.agents.list(),不读会话日志、零 surface 读取、无性能代价),只列根代理、同cwd、且非发起者自身——子代理不会被列为可投递目标。执行上下文没有会话身份时(插件内部通知路径)只知道「非自身」,此时跳过 cwd 过滤、列出力所能及的全部存活会话;无匹配时输出「当前工作区无其他存活会话。」- 首前缀
消息形状:为什么 source 必须恰好三个成员
投递的消息 source = { kind: "agent-message", form: "relay", senderSessionId }——恰好这三个成员。这不是风格选择:DSH 0.1.5 的会话日志迁移会逐条校验 source,白名单外的 kind 或成员多一个少一个,会让整份会话日志拒绝迁移(表现为「这个对话打不开」)。详见第八节。
发送方、时间、插件名都写在正文 banner 里:
📨 [跨会话消息 · 来自会话「X」(session-x) · 2026-09-17 23:42:05 · type=ruling pri=P0 ref=slp-a1b2]
接收方模型可直接看到,并可用同一工具回发。消息 id 固定为 slp-<uuid>,UI 卡片靠它把自己的中继与上游相邻代理消息区分开。
「三成员」有三个构造点,不是一个(差异审计修复轮 🔵-3 的措辞修正):本插件对 {kind, form, senderSessionId} 这个形状有 3 处字面构造——relayUserMessage(本插件驱动的那些中继:§10.2.3 启动任务、§11.4.5 交接投递、§11.2 命令指令共用的一处构造)、看门狗 tick(tickMessage,消息 id 前缀 slp-wd-、发送方是观察者自身)与 §3.4 的 team_link_send 投递(正文是信封 banner)。三处都恰三成员;relayUserMessage 的注释原先概括成「全模块唯一构造器」,那句只对「本插件驱动的中继」成立,对全模块不成立(已在注释里改成带范围的表述)。改这个形状要三处一起改——任何地方冒出第四处字面构造才是缺陷。
两道批准门与配对
flowchart TD
S["要投递"] --> BLOCK{"在 blockedSenders 里?"}
BLOCK -->|是| REF["refused(屏蔽优先于一切)"]
BLOCK -->|否| PAIR{"双方已配对?"}
PAIR -->|是| D["直接投递"]
PAIR -->|否| G1["门 1 · 发送方确认<br/>发送 / 记住该目标免确认 / 取消"]
G1 --> G2["门 2 · 接收方策略(receiveMode=ask)<br/>接收 / 总是接收该发送方<br/>配对:双向免确认 / 拒绝并屏蔽"]
G2 -->|配对| D
G2 -->|拒绝并屏蔽| REF
G1 -->|取消| C["取消(超时约 3 分钟同样按取消处理)"]
- 接收方选「配对」→ 写入
pairs: [{a, b, createdAt}],此后两会话双向免确认; - 选「拒绝并屏蔽」→ 写入
blockedSenders并自动解除配对——屏蔽始终优先于配对; - 超时(约 3 分钟)按取消处理;确认服务不可用时逐目标 fail-closed(宁可不发,不默认放行)。
广播 fan-out(targets)
targets 与 targetSessionId 互斥:两者都给是参数错误,都不给也是参数错误(单目标语义不变)。
targets 项 |
解析 | 谁能用 |
|---|---|---|
session-xxx |
直达该会话(最高优先级) | 任何会话 |
team:<name>/<role> |
该角色的现任会话 | 任何会话;角色空缺/不存在 → no-holder 结果(不算投递也不算失败) |
team:<name>/* |
全队:该团队全部在任且存活的角色(不含发起者) | 仅该团队现任协调者,否则整次调用拒绝 |
team:<name>/* 之所以收得这么紧:协调者的价值部分在于策展每个 worker 看到什么,而 flash worker 最稀缺的资源是上下文——全连通群播会让 worker 的上下文互相污染。
单次最多 8 个目标,超出即拒绝;团队不在 roster 或表达式形状非法 → 整次调用拒绝(不做「半发」);
fan-out 不放宽任何门:每个目标照走完整单目标路径(屏蔽检查 → 配对快路径 → 发送方确认 → 接收方策略 → steer/followup)。N 个未配对目标就是 N 次批准;
返回逐目标结果行:
- session-b(via team:night/worker1) → delivered:已投递(目标已空闲,唤醒为新回合) - session-c(via team:night/worker2) → refused:接收方拒绝 汇总:1 投递 / 1 拒绝 / 1 个重复目标已去重。失败领先:只要有一个目标不是
delivered,第一行就是❌ N 个目标未投递(M 个已投递)(N 含 refused / no-agent / no-holder,用词是「未投递」而非「失败」,故与汇总里 no-holder 的独立桶不矛盾),其后才是广播 fan-out:…头行、逐目标行与汇总。全部成功时文案形状一字不变;重复的会话 id(含不同表达式解析到同一会话)去重后只投一次,去重个数写在汇总里。
信封 banner(meta)
可选 meta: { type?: 'ruling'|'receipt'|'report'|'ask', pri?: 'P0'|'P1'|'P2', ref?: string } 渲染进 banner 首行的紧凑字段,只出现调用方给的键:
📨 [跨会话消息 · 来自会话「X」(session-x) · 2026-09-17 23:42:05 · type=ruling pri=P0 ref=slp-a1b2]
ref超过 16 字符按码点截断(不会切半 emoji),并在返回文案里注明截断前后;- 枚举外的
type/pri、未定义字段、非对象meta、空ref、含换行的ref→ 明确参数错误、整次调用拒绝(不静默丢弃、不部分采用); - fan-out 时所有目标共享同一
meta; source仍是恰好三成员:信封只走正文 banner,不扩 source、不做 sidecar 索引。
busy 预判
投递成功的返回文案附带目标忙碌状态(fan-out 逐目标独立):
- 目标运行中 → 追加
目标回合已运行 N 分钟(steer 注入当前回合);需新回合语义请等其空闲。N取自活性行的「回合始于」,读不到该时间戳时只给 steer 语义、不给分钟数; - 目标空闲 → 保持原文案(已唤醒为新回合)。
- 发送方卡片上是同一读数的徽标形态(不是上面那句机制文案):
忙碌 · 已运行 N 分钟,读不到回合起点时只说忙碌中——见「发送方卡片」一节的「A 面逐目标行的措辞」。
消息卡片(浏览器侧)
接收方 UI 把跨会话消息渲染为醒目的 📡 卡片(📡 标题行 + 高亮左边条 + 发送会话 + 时间):通过 conversation.chat.node keyed slot 以 priority: -100 影子替换 chat 包默认的折叠灰字行;非本插件消息(其他插件的 context 注入)经 slots.entries() 委托回原渲染器,显示不受影响。
判定条件不是「kind/form 命中」而是本插件自己的消息:agent-message + relay 正是上游相邻代理消息(send_message)用的形状,只按 kind/form 判断会把它们也渲染成卡片。因此卡片还要求命中本插件自己的特征之一——消息 id(在 chat node 上是 node.id,context 的 data 里没有 id)以 slp- 开头,或正文以 📨 [跨会话消息 开头;历史日志里的旧 kind: "team-link" 继续识别。卡片时间优先取旧日志的 sentAt,其次取 context node 自带的事件时间 data.time,最后才从正文 banner 里解析。
发送方卡片(team_link_send 自己那一行)
上面那张卡覆盖的是接收方;发送方过去只看到工具树里一行灰字。现在发送方也有卡,两条腿都在客户端:
每一块信息只准出现一次(§10.1.5 硬性条文,差异审计 F1 后写死):两张卡的可见文本取并集后,任一语句恰好出现一次。判据由 client-half.test.mjs 的 F1 三条断言把守——把任意一块搬回另一面,它们当场变红。
| 腿 | 位置 | 槽位 | 承载(且只承载这些) |
|---|---|---|---|
| A | 工具调用原地(审计记录不动) | tool.call.toolview,key = 线上工具名 team_link_send(逐字;typo 会静默回退通用工具行、不报错) |
极简标签(✦ 工具调用 · team_link_send · N 个目标)+ 逐目标行:每行「目标(expr 或短 id)+ outcome 的一句人话短语(从结构化字段渲染,见下「A 面逐目标行的措辞」)」,运行中的目标带 busy 徽标。不渲染标题/时间/正文/汇总,也不渲染 target.detail(那句是模型可见的报告句) |
| D | 会话流顶层 | 本插件自己的 uiConversation definition(kind team-link-send)+ 同 kind 的 conversation.chat.node(priority: -90) |
标题 + 发送方/时间 + 正文 + 汇总计数。不渲染逐目标明细行(目标身份归 A 的行)。与接收方的 key: "context" 卡片kind 不同,并存不冲突 |
A 面逐目标行的措辞(2026-09-20 用户决定;设计 §10.1.5 修订 + §12.5):A 的每一行不再原样搬运 target.detail——那句是模型可见的报告句,带「已投递到 …」「目标处于空闲」「steer 注入当前回合」这类投递机制,对人看的卡片偏机制而非结论,且一个目标就占 2–3 行。改为从结构化字段渲染一句人话:
target.outcome→ 一句固定短语(客户端OUTCOME_PHRASES映射到 zh/en 两套 locale 键,不是硬编码):delivered→ 「已送达」、refused→ 「未送达——接收方拒绝」、no-agent→ 「未送达——目标会话没有活动代理」、no-holder→ 「未送达——该角色当前空缺」;target.busy→ 徽标:「忙碌 · 已运行 N 分钟」(读不到回合起点时只说「忙碌中」,不编数字);- 目标身份 →
expr或短 id(不变)。
于是一行 = 目标 + 一句结论 +(忙碌时)徽标,长报告句(含机制、含 busy 的 steer 文案)继续留给模型可见的文本。这不违反「一处事实」:报告句与卡片短语是同一批结构化字段的两种渲染(outcome / busy / 身份),target.detail 仍留在回执里(模型可见文本的事实源 + 纯文本降级路径的兜底),只是不再是 A 的渲染输入。
实测对照(同一次投递,team:night-shift/* 广播;「改前」句取自 client-half.test.mjs 的旧期望值 fixture——那行 80 字符、卡片按 pre-wrap 折成 2–3 行,「改后」行 44 字符) |
文本 |
|---|---|
| 改前 A 面逐目标行 | session-worker-a(via team:night-shift/*) 已投递 已投递到 session-worker-a:目标空闲,已唤醒目标会话。 |
| 改后 A 面逐目标行 | session-worker-a(via team:night-shift/*) 已送达 |
跨半边行为锁(取代 B1 的字符串等式):旧的「卡内行 == 报告首行」断言已作废(它钉的是一个已被设计替换的渲染,且同源化之后由构造保证永不红,§12.3 ⑤)。现在锁的是行为:宿主半边能铸出的每个 outcome 枚举值都必须有客户端短语——host-half.test.mjs 同时读 lib/index.js 与 lib/client.js,把宿主侧的字面量 token 集合与客户端 OUTCOME_PHRASES 的键判等;新增一个枚举值而不给卡片短语 → 必红(红相实测见「测试」一节的对应条目)。测试报告里会打印两侧的实测集合。
数据来源是官方载体,不是解析返回文本:宿主半边给 team_link_send 加了 output.presentationMeta,产出的结构化回执落在 tool/result.meta 里(durable——回放同一份日志会重建同一张卡):
{ kind: "team-link-send", v: 1, at, senderSessionId,
meta?: { type?, pri?, ref? }, // 仅调用方给了信封才有
message: { text, truncated, chars }, // chars = 原始码点数
targets: [{ expr?, sessionId | null, outcome, detail, busy? }],
targetsTruncated?: { shown, total }, // 仅 targets 真被裁到 24 行才有
summary: { delivered, refused, noAgent, noHolder, deduped }, fanout }
- 体积纪律(两处上限,各自如实标注):
- 正文:
message.text上限 2000 码点,超出则取头 1500 + 省略标记 3 码点 + 尾 400(1903 码点,仍在限内)并置truncated: true;chars记原始码点数。裁剪与计数都按码点; - 逐目标行:宿主侧(权威)
targets封顶 24 行(SEND_CARD_ROW_LIMIT,= §10.2.4 的每队成员上限,全队广播仍可整份渲染)。界是行数不是表达式数:输入侧resolveTargetList裁的是表达式(≤8),而一个team:<name>/*就展开成该队全部在册存活成员,所以合法的行数可以超过 8——持久化的卡必须有界,tool/result.meta里被烙进日志的正是卡的targets。超出时卡内显式标注targetsTruncated: { shown: 24, total }(shown= 裁完实际呈现的行数——客户端sendRowsTruncated「已截断——仅显示前 {shown} 行」的口径同样是实际画出的行数,良构宿主卡上两者同值;total是这次投递真实的行数),而summary计数仍覆盖全量、文本报告仍逐目标完整——卡是有界呈现,报告是全量档案; - 客户端(防御 + 如实呈现;2026-09-19 收尾轮补齐跨轮读路径):A 面渲染期另设同值上限,因为
meta核心不透明且持久化,手改日志或异构实现可塞进任意条数。这条判据对宿主自产的卡永远不触发(宿主已先裁到 24,行数恰好落在界上),所以 A 面同时读宿主自己的targetsTruncated标注:只要卡上有该标注,或行数确实超过本地上限,A 面就渲染sendRowsTruncated「已截断——仅显示前 {shown} 行」——两类超限都在界面上看得出来。两处数字各有各的口径:{shown}是实际画出的行数(不是标注里写的数字——手改的shown不得在那个句子里安一个假数字),标签的「N 个目标」取真值——有标注时是targetsTruncated.total(30),无标注时是该卡自己的行数(外来卡的 30 行就是真值;而只画出的那 24 行不得当真值)。标注本身与其它meta字段同等不信任:形状不认(shown/total非有限数)即丢弃并退回「按行数判超限」的防御路径,卡照常渲染。无标注且 ≤24 行的卡可见文本与加这条读路径之前逐字一致(不增删任何文本);
- 正文:
- 良构(差异审计 F2 修正):卡内的每一个字符串成员——
message.text、逐目标的sessionId/expr/detail、senderSessionId、信封的ref——都在制卡时过一遍孤立代理项修复。卡不走textOutput.render,模型可见出口盖不住它,所以这条线必须逐个字段自己守住;回归锁在同一声明的三条断言上(污染sessionId/expr/meta.ref后JSON.stringify(card)无孤立代理项); - 降级:拿不到回执时(调用仍在飞、旧日志没有
meta、meta形状不认识、其他工具的 meta)先走「文本重建」,重建不出来才回退纯文本行(三种情形的判定表见下面「三种情形」);整次调用在走到逐目标投递之前就被拒(寻址互斥 / 无地址 /meta非法 / 执行上下文没有可交互的活动代理)时不产出卡,客户端回退文本——绝不为没发生的投递编造回执。注意区分:目标无活动代理(outcome: "no-agent")发生在投递阶段内,照常出卡,那一行就是那条❌ 未投递拒绝; - 零日志改动:A/D 都只读既有的
tool/call+tool/result事件,不新增任何日志事件类型(§10.3 红线);投递消息的source仍恰三成员;模型上下文无新增消息。
三种情形:卡片 / 文本重建 / 纯文本行(§10.1.5,DEFECT-5)
A 面(tool.call.toolview)逐块判定,顺序即优先级:
| # | 情形 | 判据 | A 面呈现 |
|---|---|---|---|
| ① | 有结构化回执 | tool/result.meta 读得动(kind: "team-link-send"、v: 1、字段齐) |
卡片,数据全部来自回执。文本重建在这一情形里永不参与(下面 ① 号锁) |
| ② | 无回执(或回执读不动),但文本认得 | 模型可见文本是我们自己产的两种形状之一 | 文本重建的最小卡:目标身份 + 结果短语 + 汇总计数从文本解出;at / 发送方 / 正文 / busy / detail 不造(文本里没有这些字段) |
| ③ | 两者都不成 | 在飞、无文本、认不出的句子、跨行/改写/自相矛盾的报告 | 纯文本行:原样显示模型可见文本,一行不吞、一处不改 |
② 什么时候会发生(实测定性,不是推断):presentationMeta 只在一等工具调用上投影(dsh-tools/lib/types/index.js:1191 的 exec.parent === undefined),所以在 run_code 程序里发起的 team_link_send 永远没有 tool/result.meta——桥为那次派发记的是 tool/ptc-dispatch,其字段恰为 {rootCallId, parentCallId, subCallId, name, arguments, isError, content}(没有 meta),客户端 chat 包据此建的块(childResult)也不带 meta。只读全量扫描本机 1311 个会话日志:team_link_send 的 ptc-dispatch 733 次、带 meta 的 0 次;同一批里 tool/result 带本插件回执的有 11 条(都来自一等调用)。⇒ 此前从代码里发出的那一次投递永远是灰行,而直接调用的一直有卡。
② 认的两种形状(我们自己的渲染器逐字产出,lib/index.js):
- 单目标成功:
已投递到 <sessionLabel>(<通道说明>):<细节>——sessionLabel是id或「标题」(id),通道说明是配对 / provisional 两种之一(也可以没有)。解析时按形状剥掉这两层,不硬编码那两句提示语; - 广播报告:可选的
❌ N 个目标未投递(M 个已投递)首行 +广播 fan-out:N 个目标[(重复目标已去重 N 个)]+ 逐目标行- <目标> → <结果>:<细节>+汇总:N 投递 / N 拒绝[ / N 无活动代理][ / N 空缺目标(no-holder,不计入投递与失败)][ / N 个重复目标已去重]。+ 可选的注意:…尾注。逐目标行的<细节>可以跨行(no-agent的拒绝是一整段,自带缩进列表),那些续行属于同一行,不会变成额外的目标行。
② 是「全有或全无」,并且自校验:报告里的每个结构事实都要与其它事实互相印证才成卡——行数 == 表头声明的目标数、汇总 四个桶 == 逐行自己的计数、去重数(表头与汇总两处)一致、❌ 首行有且仅当存在非 delivered 的行、且它复述的两个数字与逐行计数相同。任一处不符(含标签认不出、outcome 落在四种之外)⇒ 整条回落 ③,而不是丢掉那一行或猜一个桶。理由就是「不伪造」:单目标被拒的句子(未投递:… / 发送失败:…)根本不含目标身份,为它出卡就必须凭空造出「目标」这个字段——按设计那句话(缺的字段不造)它只能留在 ③。
重建出来的卡只在 A 面:D 的顶层节点由 tool/result.meta 的判别符经 definition match 驱动,所以文本重建永远长不出顶层卡。
已知边界(如实声明):presentationMeta 只对顶层工具调用投影(exec.parent === undefined),所以从 run_code 程序里发出的 team_link_send 拿不到结构化回执——那一行走的是上面的文本重建(② 情形),重建不出来才是纯文本行;D 的顶层节点在 chat 包把「回合过程」折叠起来时可能随之被折进去(tool-call 节点本身也是这个待遇)——tool/call 滚出历史窗口、只剩 tool/result 时按 context.matches 回退重建,卡片不会在长会话里凭空消失。客户端半边对 uiConversation 不是硬依赖(审计 F3):模块级 inject 只有 slots/sessions/locale,definition 走 ctx.inject(["uiConversation"], …) 动态注册,因此缺该服务的老壳只丢 D 的顶层卡——接收方卡片、工具行、复制/导出按钮、深链打开器全部照常;四条槽位注册(header 按钮条 + 三条 §10.1)各自加护栏(审计 B3;第四条为 round-1 🔵 #3 补齐),任一条被槽位拒绝也只丢那一行。
诚实声明(A4)
跨会话投递只能送达有活动代理的会话:目标已关闭、或刚重启 DSH 后尚未在侧边栏打开过,都没有任何机制能唤醒它。插件能做的只有如实告知 + 给出恢复动作,而不是假装发送成功。
二、会话可视:列表 / 深链 / 导出
team_link_list_sessions 的活性信号
每个会话行带一条活性信号(读一次 surface + 一次 agent 查询,纯服务调用,不解析日志)。
读取是有窗口的:只有列表前 12 个会话(PREVIEW_SESSIONS,与主题/最近摘要同一个窗口)会被读 surface,且这 12 次读取并行发出。第 13 行及以后照旧列出(id / 运行状态 / 创建时间 / 读数时效戳——全是零日志成本的面),但活性行降级为 活性:未读(超出快照窗口 12)……:verdict / 静默时长 / goal / 主题 / 最近动态一律标为未判定。
要读更多 = 点名或分页(两个可选、互斥的参数) —— 12 是预算(一行 surface = 一次冷日志解压 + 一次投影),不是能力上限;所以默认值不动,把「读哪些」的决定权交回调用方:
| 参数 | 语义 | 边界(一律拒绝,不静默截断) |
|---|---|---|
readIds?: string[] |
点名读:把指定会话强制纳入本次读窗(用于窗口外的行) | 去重后 ≤ 12 个,超出即拒绝且本次零读;只接受本次列表内的会话 id,未知 / 不属于本列表的 id → 拒绝并指出那个 id;点名优先占额,其余按原顺序补足到 12;本就在默认窗内的 id 只占一个额、不重复读;与 offset 互斥 |
offset?: number |
分页:从第 offset+1 行起读 12 行(默认 0) |
必须是整数;负数 / 越界(≥ 列表行数)→ 拒绝并给出有效区间 0..行数-1;与 readIds 互斥 |
成本不变量(按工具分账,断言锁死):team_link_list_sessions 的三条路径(默认 / offset / readIds)单次调用都 ≤ 12 次 surface 读;team_link_team_read 的收件视图 + 派生回执 ≤ min(12, 1+|readIds|)(自读占 1 个额);team_link_status ≤ 12。
列表末尾的「未读 Y 行 + 怎么读」提示段(默认调用也带,是提示不是载荷):把「未读」从终点变成下一步 —— 形如 未读 2 行(读窗 12/共 14 行):读第 13–24 行 → offset=12;或点名 → readIds=["session-x", "session-y"]。既有行结构一字未改,本次唯一的格式新增就是这一段(外加新参数)。
为什么要有界:一行 surface = 一次冷日志解压 + 一次表面投影。真实工作区实测(26 个会话)逐个串行读满
LIST_LIMIT(50)会直接超掉 60s 工具预算——0.3.5 修的就是这个(修前超时、修后约 15s)。这里选择有界 + 并行 + 如实标注,而不是编一个没读过的判定。
窗口内的字段:
| 字段 | 含义 |
|---|---|
verdict |
五态判定(见下) |
代理 |
运行中 / 空闲 / 未运行(读 agent 注册表) |
goal |
<phase>/<activation>(<已用轮次>/<上限>);blocked 另带 blocked=<code>: <message>;none = 当前无 goal;? = goals 服务缺失(降级运行,插件功能不受影响) |
静默 |
now - max(末条 assistant, 末条入站)(分钟);两侧时间戳都读不到时显示 ? |
| 回合始于 / 末条助手 / 末条入站 | 绝对时间戳(本地时区) |
verdict 五态(阈值:静默 10 分钟、回合 30 分钟;team_link_watch 的 silentMinutes 只影响巡逻判定):
flowchart TD
SIG["读信号:代理状态 · goal phase/activation · 静默时长"] --> D{"有存活代理?"}
D -->|无| DEAD["<b>dead</b><br/>会话已关闭:只有用户能处理"]
D -->|有| RUN{"运行中?"}
RUN -->|是| LONG{"当前回合 > 30 分钟?"}
LONG -->|是| LR["<b>long-running</b><br/>可能卡住,值得看一眼"]
LONG -->|否| OK1["<b>ok</b>"]
RUN -->|否,空闲| G{"goal 状态?"}
G -->|"active + armed"| OK2["<b>ok</b><br/>它有自己的续跑节拍"]
G -->|"active + disarmed"| GD["<b>goal-disarmed</b> ⚠️<br/>根因级静默:activation 不持久化<br/>重启 / max-tokens 结束 / agent error<br/>都会落到这里,且不会自愈"]
G -->|"paused / blocked / complete"| OK3["<b>ok</b><br/>已被解释的静默(在等人类决策)"]
G -->|无 goal| S{"静默 > 10 分钟?"}
S -->|是| SI["<b>silent-idle</b><br/>P1 场景:协调者在等 worker 回报"]
S -->|否| OK4["<b>ok</b>"]
goal-disarmed 是这个插件最想让你看见的状态:它看起来「空闲」,其实是根因级静默——goal 还是 durable-active,但驱动器不会再排队,除非人类(经模型转告后)显式 resume。插件绝不代调 goals.resume(那是人类授权门),只负责把状态摆出来、并给出合规的恢复回路。
读数带时间戳:每行行尾是 (读数 YYYY-MM-DD HH:mm:ss,>2min 作废)——活性是快照,超过 2 分钟须重新读。
深链(上游功能)
复制 dsh://session/<id>,粘贴到任意会话即注入该会话的只读快照作为上下文。这是上游 dsh-session-link 的能力,本仓库保留其解析行为不变(register-protocol.ps1 / dsh-open.cmd 负责协议注册与打开)。
聚焦这一步(打开之后真的切到那个会话)0.3.9 起修复:此前用的是 ctx.sessions.open(id)——会话服务上没有这个方法(同名的 Session.open() 是历史加载,另一回事),调用抛错被 try/catch 吞掉 ⇒ 静默空调用;见侧栏「会话工具」入口一节的末段。
team_link_export
任意会话全量导出:markdown(人可读,含信封首行)+ JSON(无损事件流)。同一个导出能力也挂在 GET /team-link/export?session=<id>&format=md|json(会话头部 ⬇ 按钮消费),路由与工具共用同一个文件名安全不变式(fileSafeSessionId())。
下载路由走平台的信任栅栏(0.3.9 起):路由只注册给同时拿到 webServer 与 connection(栅栏服务)的宿主,并且每个请求都先问一次 connection.requestRejection(req)——Host/Origin 栅栏(跨站/DNS rebinding → 403)与浏览器鉴权(未登录 → 401)都由平台裁决,裁决结果原样写回(响应体与官方的 RPC 通道一致:401 unauthorized、403 forbidden);被拒时不读会话。栅栏取不到(服务缺席、没有 requestRejection、或它在挂载之后消失)⇒ 503 + 不吐数据:宁可没有这条路由,也不要一条无门路由。非 GET 方法一律 405(allow: GET)。同源且已登录的浏览器下载行为不变。
侧栏「会话工具」入口(0.3.9 起)
侧栏底部(同一 flex 行内、位于消耗卡片右侧 —— order: 0 升序 ⇒ 排在 order: -10 的消耗卡之后、【设置】之前)多一个入口,点开是任意会话的列表:复制链接 / 导出会话 / 打开会话——不必先进入那个会话。入口注册进官方槽位 sidebar.footer.action(不改任何官方文件)。该位置已由 owner 在真机确认保持(同一 flex 行、消耗卡右侧;「改到下方」物理上不可达 —— 见下表「为什么不是下方」)。
| 面 | 行为 |
|---|---|
| 位置与形态 | id: team-link-session-tools、order: 0 —— 同一 flex 行内、位于消耗卡片 order: -10 的右侧(order: 0 升序 ⇒ 排在它之后、【设置】之前)。为什么不是「下方」:官方 .footerActions 是 display:flex(方向默认 row),且官方 sidebar 全档 flex-wrap 出现 0 次 ⇒ 不换行,而插件无法从子元素侧改变父级换行(改父级 = 改官方文件,违反设计档 §1.3 的红线 N5);2026-09-21 真机读数(owner 截图)与之一致:入口渲染在消耗卡右侧。宽态=图标 + 文字「会话工具」,收起态(56px 轨道)只渲染图标,文字转 aria-label/title |
| 弹窗 | 官方 Modal(居中、挂 body——收起态轨道只有 56px,锚定面板会被裁切):标题 + 当前计数 → 搜索框 → 会话列表(可滚动)→ 有界呈现标注 → 底部(范围切换 + 关闭) |
| 数据源 | ctx.sessions.list / ctx.workspaces.list:丢弃 origin === 'subagent'、丢弃已归档、丢弃 blank 行、丢弃当前会话(本面板的用途是「其他会话」——当前会话的复制/导出已在会话头部按钮上;丢弃它之后官方那条「blank 行只在它是当前会话时保留」自然退化为「blank 行一律丢」),按 updatedAt 倒序;当前会话 = retainedBy.mainView > 0 的那一行(官方同款约定,只用于定位当前工作区与把它从列表里剔除);默认范围 = 当前工作区,底部可切「全部工作区」 |
| 列表行 | 状态点(恰两态:运行中 / 空闲,唯一数据源 SessionSummary.running)+ 标题(超长省略)+ 相对时间(切「全部工作区」时追加工作区名);「复制链接」「导出会话」默认隐藏,鼠标悬停该行或该行获得键盘焦点才浮现;点行本身 = 打开该会话 |
| 三个动作 | 复制链接 = 剪贴板 dsh://session/<id>(与会话头部按钮同一格式)+ 短暂「已复制 ✓」+ aria-live 播报;导出会话 = 导航 GET /team-link/export?session=…&format=md(同源自动带 cookie、天然过栅栏、零 CORS 面;参数必须编码);打开会话 = ctx.uiWorkspace.openSession(id) |
| 三种空态 | 三句不同的话:读取中… / 没有匹配「xx」的会话 / 暂无其他会话(「还没读到」「搜不到」「真的没有」不许混) |
| 有界呈现 | 列表上限 50 行;超过时在列表末尾写明「共 N 个,仅显示前 M 个(搜索可收窄)」 |
| 服务缺失 | sessions / workspaces / uiWorkspace(以及 seed 模块的 Modal)缺任一项 ⇒ 入口不注册 + 一行 warn,绝不渲染一个点了没反应的假按钮;其余面(深链、消息卡片、头部按钮)照常 |
| 键盘与无障碍 | Tab 进入、Enter/Space 打开;关闭(Esc / 关闭按钮 / 成功打开)把焦点还给入口;动作按钮带独立无障碍名(含会话标题);尊重 prefers-reduced-motion |
| 边界 | 只做「看 / 复制 / 导出 / 打开」——不做会话写操作(改名 / 分叉 / 归档),不加右键菜单,不动会话行内菜单 |
| 团队面板分区(C 批,0.4.1 起) | 同一个弹窗里、会话列表之后的一个只读分区(不新造 Modal、不新造入口 —— 宿主 Modal 有独占交互契约,侧栏入口已经在且不与对话抢焦点)。取数走只读路由 GET /team-link/panel(与导出路由同一道栅栏:connection.requestRejection ⇒ 无 token 401 / Host-Origin 不符 403 / 仅 GET / 非 GET 405)。六段:落点行 → 团队与角色 → 换届 pending(token 掩码)→ 看门狗 → 台账尾(末 N 行 + 「最后主张 / 未消解存疑」派生读数)→ 会话面/读窗(未读行显示为未读 + 只读翻页)。一次渲染 = 一次路由调用(与工具面同一个「≤12 次面读」不变量),只在打开 / 手动刷新时拉,不轮询(无定时器)。降级:整条路由不可用 ⇒ 面板照常打开并显示「⚠ 面板数据不可用:<原因>」;单段取不到 ⇒ 该段显示「⚠ 本段不可用:<原因>」。渲染归属:落点行三态、台账派生读数、活性 verdict 都由宿主渲染好整串,客户端只显示不重算(「同源」由构造保证) |
已知限制:
- 收起侧栏 + Windows 标题栏模式(
[data-windows-titlebar])下,官方 CSS 会隐藏整个footArea(连同【设置】)⇒ 本入口一并不可见;展开侧栏即可用。 - 状态点刻意只有两态:第三态「无活动代理」是宿主侧事实(宿主半边读
ctx.agents.get(id)),而客户端公开面里SessionSummary没有 liveness 字段、SessionProjectionMap的三个键也没有 ⇒ 画第三态只能靠编造读数,因此不画(官方侧栏自己的状态点同样不含 liveness,口径一致)。⚠ 前提更正(C 批,2026-09-27):本条原文写的「本插件浏览器半边没有任何跨半边取数通道」已被取代 —— 通道一直是GET /team-link/export(下载路由)那条;C 批把它扩成一对只读路由,新增GET /team-link/panel(同一道栅栏)。但「会话列表的状态点仍是两态」这条结论不变,理由换成可核的那一条:会话列表渲染的是SessionSummary(客户端快照),而活性读数走的是面板那条路由(宿主渲染、一次一屏),二者不同源,所以列表行的点不冒充活性(面板自己那一段照旧给真 verdict / 「未读」)。 - 入口的可用性取决于三个客户端服务都在场(见 §九「浏览器半边的模块声明」);缺席时按上表「服务缺失」降级。
深链聚焦修复(§4.4,0.3.9 起):用 dsh://session/<id> 打开会话时,最后那步「切到该会话」此前从未生效——lib/client.js 调的是 ctx.sessions.open(id),而会话服务(ISessions 公开面)没有 open 方法(同名的 Session.open() 是历史加载,另一回事),调用抛出的 TypeError 被外层 try/catch 吞掉 ⇒ 静默空调用。现在改用公开导航面 ctx.uiWorkspace.openSession(id),运行时经 ctx.inject(["uiWorkspace"], …) 取用(缺席只丢「聚焦」这一步,不阻断打开),「等会话出现在 sessions.list 再聚焦」的重试循环原样保留,失败只留一行痕。
三、跨会话看门狗
看门狗解决的是「我(协调者)在等 worker,但没人叫醒我」——它反过来:盯住别人,别人失联且我空闲时叫醒我自己。
tick 策略
flowchart TD
P["巡逻"] --> W1{"观察者自己<br/>运行中 / armed-active?"}
W1 -->|是| SKIP1["不 tick<br/>绝不打断运行中的回合"]
W1 -->|否| W2{"观察者代理还在?"}
W2 -->|否| SKIP2["不 tick<br/>标「观察者=dead(等待用户)」<br/>注册保留至 TTL"]
W2 -->|是| T{"目标 verdict / goal 状态"}
T -->|目标 armed-active| S3["不 tick<br/>它有续跑节拍"]
T -->|silent-idle| TK["tick<br/>同一静默期最多一次"]
T -->|goal-disarmed| TK2["<b>立即</b> tick(不等静默阈)<br/>载荷带诊断 + 合规 resume 回路"]
T -->|dead| TK3["tick<br/>但唤不醒它:标 dead 等用户"]
T -->|paused / blocked / complete| S4["不 tick<br/>只在活性行展示"]
| 目标 goal 状态 | tick? | 理由 |
|---|---|---|
armed + active |
永不 | 它有自己的续跑节拍,tick 只稀释节奏 |
active + disarmed |
立即(不等静默阈) | 根因级静默态;载荷带诊断与合规恢复回路(转告用户 → 用户授权 → 模型自己 update_goal(action:"resume")) |
paused / blocked / complete |
不 tick | 在等人类决策 / 已完结 |
| 无 goal | 静默超阈才 tick | P1 场景 |
观察者侧:自己运行中或自身 armed-active 时不 tick;自己代理不存在时不 tick、不改注册(该会话在活性行里本来就是 代理=未运行,watch list 另标「观察者=dead」,注册保留到 TTL 到期自清——代理回来了就自然恢复投递)。
限制与实现约定
register只能给自己注册(观察者 =exec.agent.id),且targets不能含自己(自指等于变相的自 tick 定时器);- 单会话最多 3 个注册;
silentMinutes >= 10(默认 10);intervalMinutes >= 5(默认 5);ttlHours <= 24(默认 12,到点自动清理);clear幂等且只能清自己的; - source 三成员不变:
{ kind: "agent-message", form: "relay", senderSessionId: <观察者自身> };消息 id 前缀slp-wd-; - 正文是插件常量模板,只有状态字段插值(目标 id / 读数时间 / 静默时长)——注册参数不进入正文,注册无法给观察者的下一回合夹带提示词;
- 去抖:同一目标「同一静默期最多一次 tick」,两次 tick 之间至少隔一个巡逻间隔。去抖状态是进程内 Map,不持久化——重启即忘,宁可多一次 tick,也不留会误判的持久状态;
- 巡逻定时器随插件 dispose 一起清理(
ctx.effect)。
诚实声明(A4)
看门狗只能提醒活着的观察者。观察者或目标任一方已关闭时,没有任何机制能唤醒它——插件只在信号面标 dead 等用户处理。「活会话节奏维持」是真实覆盖面,「失联恢复」不是。
团队状态卡(只读)
team_link_status 回答的是「人想知道团队还活着没有」——夜里三点团队卡住时,一次调用一屏讲清现状(设计档 §4.2)。全只读:不写设置、不写磁盘、不加缓存、不新增持久状态;调用前后 settings 与文件逐字节不变(连换届过期清扫都不跑——那会写)。
| 段 | 内容 | 成本 |
|---|---|---|
| ① 团队与角色 | 每个团队一行(workspace / policy.writer / 角色数)+ 每角色一行:在位(现任 id 与任期起点)或空缺(vacant),外加版本史末条 | settings 读,零 |
| ② 换届 pending | 在飞令牌:团队/角色 → 继任者、token 掩码(tok-1a2b…9f0e)、创建 / 到期、绑定三元组、已迁移条数 |
settings 读,零 |
| ③ 看门狗 | policy.watchdogs 的每一条:观察者(附代理状态)→ 目标、静默阈、巡检间隔、到期 |
settings 读,零 |
| ④ 会话面 | 同工作区其他会话的 id / 代理状态 / 创建时间 / provisional 配对标记 | 注册表读,不读日志 |
| ⑤ 活性 | 与 team_link_list_sessions 同一顺序读前 12 行(PREVIEW_SESSIONS):verdict / 代理 / goal / 静默 / 时间戳;超出窗口的行如实标未读 |
一次有界并行 surface 读(≤12) |
| ⑥ 台账尾 | 每个团队的 tasks.md 末尾 tasksTail 条(默认 5、上限 20,超出拒绝)与 baseHash |
每团队一次文件读 |
| ⑦ 形态(B 批追加) | 每个团队一行形态 + Lead;成员名册投影(读时现算;不可读时如实标注并点名原因);完整能力矩阵(事实源 = 父档 §2.3)+ 那句结论;形态诊断行(除自己外没有其他可达会话时只提示、绝不自动切档) | 宿主投影读(一次 ctx.get("agentTeams") 调用,零日志)+本卡已读到的会话面 |
| 参数 | 语义 | 边界 |
|---|---|---|
team? |
只看这一个团队 | 省略 ⇒ 聚合设置中的全部团队(每团队一段,不按工作区过滤——团队在本插件里按名全局寻址,与会话列表的工作区过滤不是一回事);给了但不存在 ⇒ 拒绝并列出已知团队名 |
tasksTail? |
台账显示最后几条 | 默认 5;上限 20,超出或非整数 → 拒绝并给出有效区间 1..20 |
反面预警注记(§4.2,会诊提出):窗口内一条消息都没有、而台账同期新增了行 ⇒ 说明大家可能已经停止说话、只写行(互核正在死亡的前导指标)。判据写死:锚 T0 = 本读窗内最旧的一条 surface 消息时间戳;N = T0 之后、本读窗内的消息条数;M = T0 之后 tasks.md 的新增行数;只有 N == 0 且 M > 0 才打注记,否则不打。两个计数都取自已经读到的东西 ⇒ 零额外读。三种情形逐条写死(设计 §4.2,三态各有断言):① 读窗为空(本工作区没有其他会话)⇒ 整段不打印(连段标题都不出现);② 读窗非空、但窗内没有一条带时间戳的 surface 消息(锚取不到)⇒ 打一句 (反面预警注记无法计算:本读窗内没有带时间戳的 surface 消息——不猜。),诚实优于沉默,台账里有多少行都不改这个结论;③ 锚取到 ⇒ 按上面那条唯一判据判 触发 / 不触发。聚合口径:省略 team 时 M = 所示各团队 tasks.md 同期新增行数的和(单团队时就是那一家)——读面上它只是一个数,不逐团队拆分。它是读数,不是裁决:只陈述计数,不下「互核已死」的结论。
诚实声明:⑤ 段只读前 12 行会话(与列表工具同一条预算、同一种标注),第 13 行起的活性未判定;调用方没有会话身份时卡照出,「当前会话」处写 (当前会话未知)——不编造身份。
(⑦ 形态由形态批追加:卡尾一段给出每个团队的形态 + Lead、宿主成员名册投影、完整能力矩阵与「单会话兜底档」那句结论,必要时附形态诊断行——见下一节。)
团队形态(多会话档 ↔ agent-team 档)
形态不是功能开关,是通道选择(设计档 §4.1,来源是《台账 + 形态设计》§2.3):
- 多会话档(
sessions,默认):平级会话之间,靠本插件的跨会话身份与信任层(投递 / 配对 / 换届 / 黑板); - agent-team 档:一个会话内部的子代理团队(宿主 Agent Teams)。本插件在这一档下只做名册与指路,不代理宿主的派活 / 任务板(那是宿主的属地)。
三句要紧的话:
- 人说了算:人在对话里说一声 → 模型发起 → 弹一次确认框 → 人点一下才落笔(并往
decisions.md记一行形态史)。没有自动切档; - 切了就换通道,不换人:信任不自动迁移(旧的配对必须人重新批准,切回来也一样);切档不改投任何在飞消息、也不建桥——要跨形态说话,由模型自己用对应形态的手段说;
- 宿主没开就报错指路,绝不悄悄退回多会话档——「悄悄降级」正是本仓最反对的那类事故。
两档各能用什么(能力矩阵,事实源 = 父档 §2.3)
| 能力 | 多会话档 | agent-team 档 |
|---|---|---|
跨会话投递(team_link_send) |
✅ 主用途 | ❌ 成员是子代理,不是可投递目标 |
| 双门批准 / 配对 | ✅ | ❌ 用不上(宿主那套没有独立信任模型) |
换届(rotate)/ 恢复(recover) |
✅ | ❌ 没有可换届的会话;Lead 会话没了团队就散了 |
看门狗(watch) |
✅ | ❌ teammate 不是根代理,盯不了 |
| 黑板(decisions / discipline / tasks) | ✅ | ⚠️ 仍可用,但只有 Lead 一方读写 |
| roster 身份 / 版本史 | ✅ | ⚠️ 只记「本团队是 agent-team 档 + Lead 是谁」 |
| 团队状态卡(只读) | ✅ | ⚠️ 成员部分读宿主投影(2026-09-27 已核:本部署已挂载该服务 ⇒ 探针 available=true) |
| 会话深链 / 导出 | ✅ | ✅ 与形态无关 |
结论:agent-team 档应叫「单会话兜底档」,不是「另一种平等的形态」。多会话不可用时才切过去,切过去就等于放弃跨会话的全部能力。
怎么切、怎么看(team_link_roster)
| 面 | 行为 |
|---|---|
| 切档 | team_link_roster action=set-mode:mode(闭集 sessions / agent-team,闭集外拒绝并列出两个值)+ leadSessionId?(省略 ⇒ 默认取调用方自己的会话 id,并在确认框正文里明示,人可见即可否决;显式空串 ⇒ 拒绝;非空须过形状 ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ 与在场校验(能在本次会话列表快照里找到)两道) |
| 双重门(缺一即 fail-closed、零写入) | ① writer gate 原样(policy.writer=coordinator 时只有现任协调者可写;空缺 ⇒ 会话路径拒绝,设置 UI 仍是兜底);② 人类确认框:无确认服务 / 超时(3 分钟)/ 取消 ⇒ 拒绝且零写入 |
| 宿主探测只对「切到 agent-team」这一向 | 探测不到 ⇒ 拒绝 + 指路怎么开 + 零写入(绝不把 agent-team 请求当多会话执行);切回 sessions 不探测(回默认档永远可用)。探测面是宿主服务 agentTeams(ctx.get,可选服务:缺席只影响这一项能力)。同一判据只探一次:切到 agent-team 时探过的那一次直接复用为确认框正文里的成员名册投影。只报现象、不替宿主下结论:拒绝文案说的是「服务 agentTeams 当前不可见(可能未装载该组合包,也可能尚未激活完成)」—— ctx.get 只返回已激活的 provider,一个尚未完成初始化的服务也会走到这一支,所以这里不断言「profile 没装载」 |
| 落笔 | settings 的 teams 行写入 mode / leadSessionId(只改本团队行的这两个键;teams 是整份数组回写,所以其余团队行若缺这两个键会被补默认值(sessions / 空串)、已有值一个字不被覆盖 —— 与既有 upsert-team 同形,语义无变化。信任 pairs/trustedSenders/rememberTargets 一行不写)+ decisions.md 追加恰好一行形态史(形态 → agent-team(Lead=<id>) 或 形态 → sessions)+ best-effort 重写 roster.md 镜像(与设置变更同一事务;镜像与形态史同源 —— 都取写时复检后的那一行,确认框期间设置变了也不会「账写旧路径、镜像写新路径」) |
| 幂等 | 同档同 Lead 且账上已有这一档的形态史行 ⇒ 「已是该档」,不弹框、不写 settings、零写入(首次声明也算一行:形态史记的是「声明过这一档」,不是「档位发生变化」—— 所以从未切换过、第一次收到默认档声明的团队也会补记一行,返回体如实标「补记」);账上缺那一行(上一次 settings 写成功而 decisions.md 写失败)⇒ 补记恰好一行再返回 —— 否则「补上路径后重发本次切换以补记」那条指路不可执行;账读不出来或没有 workspace ⇒ 不补记并如实说明(fail-closed,不猜) |
镜像(roster.md) |
D3 起镜像也渲染 - 形态(mode):… / - Lead 会话(leadSessionId):… 两行。渲染器永不打印 undefined:缺字段 / 空串 / 非字符串按 normalizeTeams 的同一套缺字段语义落默认值(sessions / 空 Lead),闭集外的值按读面的规则落默认档(降级留痕只在读面,镜像不带);upsert-team 新建时就把这两个默认值显式写入 settings ⇒ 「settings 行 = 读面 = 镜像」三者一致(2026-09-27 真机冒烟修复轮:新建团队的镜像此前渲染成 undefined —— 只有新建这条路会,既有团队走归一化读面看不出来) |
| 切走前的提示(提示式,不阻断) | 确认框正文列出「不会迁移的三件事」:① 信任(配对 / 白名单 / 记忆目标 —— 都要人重新批准)② 成员名册(agent-team 的成员不会变成会话)③ 任务归属(宿主的任务板不跟随;台账仍是本插件的账);并列出当前 teammate(可读则列,不可读如实说),加一句「请确认它们的结论已落到黑板或文件」。不逐成员推断有没有交卷 |
| 看形态 | team_link_roster action=get 的形态段与状态卡第 ⑦ 段:形态 + Lead + 成员名册投影(读时现算,一行不落 settings;来源文案:可读时写 来源:宿主 agentTeams 投影(本部署可读);不可读时按分支取首行 —— 只有服务缺席才说 成员名册不可读(本部署未提供该投影)—— 仍可读 Lead 与形态,其余四条分支(缺方法 / 无会话身份 / 调用方非成员 / 读取抛错)用中性首行 成员名册不可读 —— 仍可读 Lead 与形态(哪一条读不到,见下一行的原因),真正的原因由紧随的(不可读原因:…)行点名)+ 完整能力矩阵 + 那句结论 |
| 形态诊断行 | 本工作区除自己外没有其他可达会话 ⇒ 读面附一行「⚠ 多会话通道看起来不可用(列表里没有其他会话)—— 你可以让协调者切到 agent-team 档(team_link_roster action=set-mode)(只提示:本插件绝不自动切档…)」。只提示,绝不自动切;没有会话身份时不判 —— 判据是 null(不判、不猜,与「列表读不到」同一处置),而不是拿一个猜出来的工作区当结论 |
红线(形态批 §5):只读宿主(不 spawn / 不派活 / 不改宿主任务状态)· 不悄悄降级(写路径刺眼报错、读路径如实降级,两条路不许混用)· 信任不自动迁移 · 双重门 fail-closed · 指针不拷贝(只存 Lead 会话 id)· 切档永远由人触发。明确不做:不代理宿主派活 / 不读子代理日志(交卷只做提示式)/ 不自动切档 / 不做跨形态改投。
四、团队:roster 与黑板
roster(team_link_roster)
teams 键是身份层的事实源:团队 → 角色 → 会话,带版本史。
teams:
- name: night-shift # [a-z0-9-]+,工作区内唯一(也是黑板目录名)
createdAt: 1700000000000
workspace: D:/work/night # 首次创建团队时从该会话的 agentCwd 捕获,之后不再改写
policy: { writer: coordinator } # coordinator | any
mode: sessions # 形态(B 批):sessions(默认,多会话档)| agent-team(单会话兜底档);闭集外的值读时降级为 sessions 并留痕
leadSessionId: "" # 仅 agent-team 档有意义:Lead 指针(空串 ⇒ 读面标「Lead 未知」);成员名册一行都不落这里
roles:
- role: coordinator # 约定角色名;自定义角色(reviewer 等)由 set-role 按需创建
current: session-abc # 现任;null = 空缺
pending: null # M4 rotation 的继任槽位,M2 只原样保留
history: # 版本史:一段任期一条,until: null 表示仍在任
- { session: session-old, from: 1700000000000, until: 1700009999999, note: 交班 }
| action | 效果 | 写权限 |
|---|---|---|
get |
全体团队概要;指定 team 时给出详情(含 pending 与整段版本史) |
任何会话可读,无门 |
upsert-team |
创建(默认 policy.writer=coordinator、workspace 取调用会话的 agentCwd,并把调用会话播种为该团队 coordinator 现任——创建即认领)或幂等更新 |
已存在的团队过写权限门;重复调用不重置 roles / 版本史 / createdAt,也不会再播种一次 |
set-role |
current 替换 + 版本史追加:旧任那条记 until=now(带 note),新任那条以 until: null 打开;角色不存在则本次指定即创建。指定的会话正是某个在飞换届 pending 的继任者时,该令牌当场作废(身份已由本次显式变更,claim 会报「没有 pending」) |
过写权限门;不迁移 pairs——信任迁移是 rotation 的专属动作 |
retire |
current 置空(vacant)+ 版本史记退役(until=now,带 note)。退役本身不动信任数据,随后弹一个确认对话框列出所有仍指向该会话的 pairs / trustedSenders / rememberTargets,选「清理」才删除 |
仅现任协调者会话或用户发起(与 policy.writer 无关) |
set-mode |
切团队形态(sessions / agent-team):mode(闭集,闭集外拒绝并列出两个值)+ leadSessionId?(省略 ⇒ 默认取调用方自己的会话 id 并在确认框正文里明示;显式空串拒绝;非空过形状 + 在场两道校验)。宿主探测只对「切到 agent-team」这一向(探测不到 ⇒ 拒绝 + 指路 + 零写入 + 补一行设置 UI 的兜底路);双重门:writer gate 原样 + 人类确认框(无服务 / 超时 / 取消 ⇒ 拒绝且零写入);落笔后 decisions.md 追加恰好一行形态史 + best-effort 重写 roster.md 镜像;同档同 Lead 且账上已有该行 ⇒ 「已是该档」(不弹框、不写 settings、零写入),账上缺该行 ⇒ 补记一行再返回(任一档都可执行的补记路径)。信任一行不写、成员名册一行不落 settings |
过写权限门(与 set-role 同一道) |
写权限与「创建即认领」
coordinator(默认):只有coordinator角色的现任会话可写(比对exec.agent.id);any:任何会话可写;- 读永远开放;
policy本身只有用户能改(工具参数里没有 policy 槽位)。
0.3.7 修掉的一个死锁:upsert-team 的创建路径本来就不该过写权限门(否则没人能建第一个团队),但 set-role 必然过门——而门在「writer=coordinator 且现任空缺」时拒绝一切会话路径。于是旧版本会出现:模型能建出团队,却永远写不进首任协调者,团队到手即只读。现在创建路径把调用会话直接播种为 coordinator 现任,团队建完即可派活/写黑板,不需要手改任何配置。手写出来的空缺行仍然全拒(那是用户显式表达的状态)。
自助引导(手写设置)
正常流程不需要这一步。手改 settings.yaml 只在两种场合需要:① 把某个已存在团队的现任换成别的会话,或把被手写成空缺的团队救回来(此时写权限门会拦下一切会话路径,工具走不通);② 用户不在模型回路里时预先铺设团队。
直接编辑 profile 的 settings.yaml,在 team-link: 段下写入(键名与第六节一致):
team-link:
teams:
- name: night-shift
createdAt: 1700000000000
workspace: D:/work/night
policy: { writer: coordinator }
mode: sessions # 形态:sessions(默认)/ agent-team;闭集外的值读时降级为 sessions 并留痕
leadSessionId: "" # 仅 agent-team 档有意义(Lead 指针;空串 = 读面标「Lead 未知」)
roles:
- role: coordinator
current: session-abc # 现任会话 id;null = 空缺(会话路径会全拒)
pending: null
history:
- { session: session-abc, from: 1700000000000, until: null }
- 保存即生效:
dsh-settings-file的 watcher 会热加载(2026-09-18 实测:外部删除team-link:段后,team_link_roster action=get立刻回到 0 团队);若个别环境不触发,重启 DSH 即可确定性地重新加载; - 只写
current不写history也能生效(任期起点退回createdAt显示);补一条until: null的任期记录才让版本史自洽; - 名字不合
[a-z0-9-]+的团队行、没有role的角色行会被归一化丢弃,不会让整个命名空间失效(其余行照旧生效); - 该命名空间只有在插件成功注册后才存在;
settings.yaml里的team-link:段在第一次真正写入后落盘——注册失败或迟迟未挂载会在日志里留痕,见第七节。
黑板(team_link_team_read / team_link_team_append)
以 team.workspace 为根:
<workspace>/team/<name>/roster.md # roster 镜像(插件同一事务内 best-effort 写;失败只告警,settings 是事实源)
<workspace>/team/<name>/decisions.md # 裁决账本:只追加,每行 `seq | ISO 时间 | author-session-id | 正文`
<workspace>/team/<name>/discipline.md # 纪律条款:整文件替换,必须携带 team_read 返回的当前 baseHash
<workspace>/team/<name>/tasks.md # 任务台账:只追加,每行 `seq | ISO 时间 | author-session-id | kind | task | 正文`
黑板是三件套:decisions(裁决)、discipline(纪律,唯一带乐观锁的一份)、tasks(任务台账)。三者都只受同一条单行上限约束,seq 各自独立计数(tasks 的 seq 不受 decisions 影响)。
team_link_team_read(team)一次读齐:roster 概要 +decisions末 20 条 +discipline全文 +tasks末 20 条与它的派生视图 + 三个文件的baseHash(sha256 前 16 位十六进制)。文件不存在按空处理并如实标注(含「空内容哈希」);team_link_team_append(team, file, line, baseHash?, kind?, task?):decisions只追加(无需 baseHash,正文必须单行);discipline整文件替换(baseHash 缺失或不匹配即拒绝并要求重新team_read);tasks只追加一条主张(下一小节)。三者都受单行 500 字符
…
评论
评论存放在 GitHub Discussions。用 GitHub 账号登录后可发表评论或点表情。