dsh-team-link
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 可自由变更 | 1442 + 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))—— 该条的红是缺席( 一档的行为在修复前已成立,如实标 ★ 负相、不冒充红相);(2026-09-27,2 🟡 + 4 🔵)新增 条断言(host 6 + client 6, / ):面板类名↔规则 2 · 翻页不回卷 3 · 降级句随页 1 · 派生行归属 2 · 2 · 完备 2,打在的实现上(聚合红相 / ),另 2 条为控制/对照项 ★ 负相(本就绿)、1 条为实施后补强且(如实标注,不冒充)) |
我们不可被替代的四件事:跨进程 / 跨团队 root 的协调 · 平级会话之间的信任(双门、配对、对称吊销)· 身份的生死与任何一个会话解耦(换届换人、退役留痕、家谱可查)· 人在环的授权点。内置那套更擅长的则是同一间办公室里的快速分工(子代理随叫随到、任务板可改状态、状态变更零 token)。
两者结构上正交,不存在二选一:内置的成员(子代理)不在本插件的投递面上(本插件的投递面只列根代理),所以按场景选就行 —— 要跨会话、要信任、要交接 → 用本插件;要在一个会话里快速并行 → 用内置那套。
与常规 agent team 框架(CrewAI / AutoGen 一类;一般性描述,未逐版本核实)的差别同源:那些框架把多个角色放进同一个进程、同一个编排器里,靠函数调用或共享 memory 传消息 —— 没有独立会话、没有跨进程信任问题,团队随主程序生死。
14 项逐项对照、三条结构性差异与逐条出处见 docs/comparison-agent-teams-2026-09-26.md。
目录
实战效果:两个真实项目的长跑记录
下面两个案例来自连续多天、经历多次换届的真实多会话项目,全程由本插件的协调会话(团队里的「主管」)驱动,工作线是人类可见的独立会话。本项目的部分机制即来自跨会话协作时模型的自主涌现。
案例一|命令行工具项目:换届 + 夜班自主推进
| 过程指标 | 读数 |
|---|
| 团队规模 | 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_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;arm-team = 现任协调者一条命令幂等补挂自己队的工作台注册,非现任 / 队不在名册会被拒绝并点名原因) |
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 / / 后 无孤立代理项);
三种情形:卡片 / 文本重建 / 纯文本行(§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 / 关闭按钮 / 成功打开);动作按钮带独立无障碍名(含会话标题);尊重 |
已知限制:
- 收起侧栏 + 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 的每一条:观察者(附代理状态)→ 目标、静默阈、巡检间隔、到期;末尾一行 attach 后置链读数(rearm 挂上几个定时器 · 段同步变化几项 · 按队补挂 created/present/skip/failed + 首因,见 FR-17;skip=没有观察者/没有可盯成员,failed=写入失败或定时器未挂上)—— 本部署没有日志出口,这一行就是那条链唯一的眼睛 | settings 读 + 进程内记录,零写 |
| ④ 会话面 | 同工作区其他会话的 id / 代理状态 / 创建时间 / provisional 配对标记 | 注册表读,不读日志 |
| ⑤ 活性 | 与 team_link_list_sessions 同一顺序读前 12 行(PREVIEW_SESSIONS):verdict / 代理 / goal / 静默 / 时间戳;超出窗口的行如实标未读 | 一次有界并行 surface 读(≤12) |
| ⑥ 台账尾 | 每个团队的 tasks.md 末尾 tasksTail 条(默认 5、上限 20,超出拒绝)与 baseHash | 每团队一次文件读 |
| ⑧ 资源/预算(批 3 追加) | 每个会话一行:压力 / 窗口 / 累计 / 轮次(只读 sessionProjections,读数不可得则整行标「不可读」+ 原因码);达标会话行尾挂 ⚠ 轮换候选(turns / tokens) 并给出那句现成命令 team_link_rotate action=prepare successor:auto(只标记、不自动);末行是判读行(「预算耗尽」只在这里、且只以否定式出现;末尾固定接一句范围界定——本节只判上下文/预算,宿主工具闸门(如写入自动评审 429)属另一类、不在本节口径内,见 FR-14)。段体 ≤12 行(行数吃紧时先给判读行与轮换标记留位,被裁的只能是明细行并如实标注「已省略 N 行明细」,见 FR-15);policy.sections.resources=false ⇒ 整段不出现 | 投影读,零写 |
| ⑦ 形态(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 写成功而 写失败)⇒ 再返回 —— 否则「补上路径后重发本次切换以补记」那条指路不可执行;账读不出来或没有 workspace ⇒ 并如实说明(fail-closed,不猜) |
红线(形态批 §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 字符上限(按码点计);file 是白名单枚举(decisions / discipline / tasks),任何路径形状都会被拒;kind / task 只对 file=tasks 生效,把它们传给另两个文件一律拒绝(参数误用不静默生效);
- 黑板没有写权限门(任何会话可写):写入者身份记在行的 author 字段里,透明可审计;
discipline 的整文件替换靠 baseHash 串行化。
任务台账 tasks.md(§9 第一批 · 只追加)
它记的永远是「谁在什么时候声称了什么」,不是「事实是什么」——这条是设计红线(见 设计档 §9):
seq | ISO时间 | author-session-id | kind | task | 正文
41 | 2026-09-26T21:10:02.123Z | session-lead | plan | t-7 | 让 worker-b 复核 §3 的行号
42 | 2026-09-26T21:12:44.001Z | session-wb | claim | t-7 | 接了,预计 10 分钟
43 | 2026-09-26T21:19:31.552Z | session-wb | done | t-7 | 核出 3 处错(依据 lib/index.js:3548)
44 | 2026-09-26T21:21:08.900Z | session-lead | dispute | t-7 | 对 43 存疑:第 2 处的行号我读到的不一样
45 | 2026-09-26T21:24:55.310Z | session-wb | retract | t-7 | 撤回 43 的第 2 处:确认是我读数窗口旧了
kind 是闭集:plan(派活)/ claim(接活)/ done(声称完成)/ block(卡住、需要授权)/ dispute(存疑)/ retract(撤回自己此前的某条主张)。闭集之外的 kind、以及缺失的 kind,一律拒绝并列出这六个值;
- 任务号由插件分配:
kind=plan 扫整个文件取已登记的最大号 ⇒ 写 t-<max+1> 并在返回体里给出这个号(此时不要再传 task,传了就拒);分配只接受安全整数——文件里若有手写的、超出安全整数范围的任务号(22 位以上,Number 会读成 1e22),plan 拒绝并点名那个号,不钳制、也不猜(t-1e+22 这种连它自己都解析不了的行永远不会被写出来);其余 kind 必填 task,且该号必须已在本文件登记过——没登记过的号一律拒绝并指路 kind=plan(这条正对着「凭记忆转录 id」那类事故);
- 只追加(
appendFile):写后的文件 = 旧内容 + 新行,既有行逐字节不变。撤回也是一条新行,历史行永远不被改写;
- 正文单行、非空、≤ 500 码点(与另两个文件同一上限,astral 字符记 1);含 CR / LF 一律拒绝;
seq 每文件独立计数:tasks.md 的 #1 与 decisions.md 的 #26 互不相干;
- 并发最坏情况如实声明:黑板无锁,两个
plan 同时扫到同一个最大号时会撞号——该号下多出一行 plan,读面照显。这与「两条同 seq」同族,不引入锁。
收件视图与派生回执(同一块,只读调用方自己的会话面)
team_link_team_read 在 tasks 块之后还追加一块(既有四块一字未动,唯一新增就是它):
- 收件视图(谁对我说了什么):读调用方自己的会话面,抽出正文里带
ref=t-<n> 的消息(meta.ref 的映射约定;归组面 = 任何消息文本里第一个 ref=t-<n>——不要求 banner 形状,也不排除本会话自己发的消息,取不到发送方会话 id 时如实回落标 (本会话)),按任务号分组给出「谁 · 什么时候 · 开头几个字符」。两个尺寸常量:读最近 20 条消息(TASK_INBOX_MESSAGES)、每条预览 80 码点(TASK_INBOX_PREVIEW,越界截断并标注省略)。没有 ref 的消息不进视图但如实计数;ref 指向从未登记过的任务号时照样分组显示,只在该组标注 (该号在台账里未登记 —— 可能是笔误,也可能是先发后记)——不静默丢弃。
- 派生回执(我发出去之后,对方动了没有):纯派生、零新增持久状态。投递事实取自调用方自己会话面里的
team_link_send 调用(既有的 tool/call,或 run_code 里发出的 tool/ptc-dispatch——只读既有事件类型,不新增)。结构化回执按 callId 配对:tool/result 的 meta 挂回那一次调用,用回执里投递当时解析出的目标与结果(全队表达式在那一刻已展开,换届之后仍指向当时那个会话);没有回执(run_code 那一类)才按实参兜底解析寻址表达式,其中 team:<name>/<role> / team:<name>/* 是按读取时的名册解析的 ⇒ 该行如实标注「按当前名册解析(该表达式按读取时的名册解析,未必是投递当时那一个)」(裸 id 是字面量,不带此标注)。窗口的单位是「最近 20 次 team_link_send」(TASK_INBOX_MESSAGES),不是 20 条消息;渲染行再截同一个常量,超出时给一行 (本次还有 M 行未显示:一行 = 一次投递里的一个目标;同一屏界常量 TASK_INBOX_MESSAGES)(M = 真实差额,不静默截断)。反应判据写死四态:
| 情形 | 读数 |
|---|
未点名读目标 T | 未读(未点名读该会话;要读 → readIds=["<id>"]) —— 不猜 |
点名读了 T,投递时刻 t 之后存在 assistant 事件 | ✅ 有反应(HH:mm 起有 assistant 事件) |
点名读了 T,t 之后没有 assistant 事件 | ⚠ 无后续反应(该时刻之后无 assistant 事件) |
回执里 outcome != delivered 的那一笔(第 4 态) | 未投递(outcome=<回执里的原值>) —— 只陈述回执自己的 outcome,不下结论(不写「失败」「被拒」这类裁决词,也不冒充上面三态里的任何一个:那一笔根本没送到,谈不上「有没有后续反应」) |
成本不变量:≤ min(12, 1+|readIds|) —— 自读占 1 个额,所以目标面最多 11 个;未点名时目标面零读(断言锁死,防它悄悄变贵);超出读额的点名逐个点名标「未读」,不静默丢。读不到的面一律标「未读」,绝不用空数据算读数。
它是读数,不是裁决:「无后续反应」既可能是没接住,也可能是还没想完——卡里只陈述事实(有没有后续事件)。台账 = 谁声称了什么;收件视图 = 谁对我说了什么。
读面给的是「派生读数」,不是裁决(§9.3.4):team_link_team_read 在原始窗口(末 20 条)之前给一段派生视图,扫描最近 500 行,逐任务一行:
--- tasks.md(只追加台账;显示末 20 条)---
共 5 行,显示 5 条 · baseHash=<16hex>(仅供参考/审计:tasks 只追加、不接受 baseHash 参数)
(派生读数:扫描最近 500 行;逐任务给「最后主张」与「未消解存疑」——是读数,不是裁决)
- t-7 · 5 行 · 最后主张 done(2026-09-26 21:19 · session-wb · 已由 #45 撤回 · 撤回者 session-wb)· 存疑已由 #45 消解
- t-8 · 1 行 · 尚无主张(仅登记)
(原始行)
41 | 2026-09-26T21:10:02.123Z | session-lead | plan | t-7 | 让 worker-b 复核 §3 的行号
- 最后主张 = 该任务最后一条
claim / done / block;一条都没有 → 「尚无主张(仅登记)」;
retract 不抹历史:被撤回的主张仍然显示,只是标上「已由 #N 撤回 · 撤回者 …」——原主张者与撤回者两个 id 都出现(谁有资格撤回由人判断:黑板没有写权限门);多条撤回时 #N 取最后一条,撤回条数不另印(原始行里逐条可见);
- 未消解存疑 = 有
dispute、且其后没有该任务的 claim / done / block / retract → 标 ⚠ 未消解存疑 N 条;否则标「存疑已由 #N 消解」;
- 绝不自动裁决:派生只做「取最后一条 + 看其后有没有新主张」,不比较正文、不按时间戳定胜负。措辞一律「最后主张 / 未消解存疑」,不输出「状态=完成」这类把主张写成事实的词;
- 三类边界如实标注:文件不存在 → 「(文件不存在,按空处理:0 行)」;超过扫描上限 → 「(派生只扫描最近 500 行;更早的 N 行未参与派生)」;不可解析行 → 「(N 行无法解析,未参与派生)」;
- 时间基(渲染 = 本地时基,原始行 = UTC ISO):派生视图那个时间戳(上面样例里的
2026-09-26 21:19)是本地时基 YYYY-MM-DD HH:mm,与同一屏末尾那句 (读数 …) 同一套本地格式化 —— 两处是给人对比用的(「这条主张是刚发生的吗」);而 (原始行) 之下的原始行第 2 列是 toISOString() 的 UTC 值,逐字保留(事实源,可排序、可外部消费)。两者相差一个时区偏移,不是同一条串的截断;时间列解析不出日期的手写行单行化后回显该列(preview() 会把内部连续空白折叠成单个空格并 trim;不编造时间、也不打印 NaN)。(切 ISO 串前 16 位是 2026-09-27 真机冒烟修复轮废弃的旧做法——真机同屏差 8 小时。)
明确不做(本批):不做状态机 / CAS / 自动裁决 / 自动推进(「送达即完成」禁止)、不做写入权限门、不做跨会话物理队列;decisions 与 discipline 的语义与文案一字未改(唯一例外是白名单拒绝文案由两值改三值)。
实现约定(设计未明说、按既有约定裁决的细节):
workspace 只在团队首次由会话创建时捕获,过后永不改写。用户手工建的行若 workspace 为空,第一次会话侧 upsert-team 会补记它(唯一一次例外,否则该团队的黑板永远没有根);
- 版本史按任期记:一段任期一条,
until: null 标在任;set-role / retire 关掉旧条目并写 note。首次指派(无旧任)把 note 记在它打开的那条上,避免 note 丢失;
- 镜像里时间戳统一
YYYY-MM-DD HH:mm:ss(本地);decisions / tasks 行里的时间是 toISOString()(UTC 带毫秒),便于排序与外部消费;而 tasks 的派生视图渲染出来的时间戳是本地时基 YYYY-MM-DD HH:mm(与同屏「读数」同一套格式化)—— 渲染 = 本地时基,原始行 = UTC ISO,见「任务台账」一节;
- 没有会话身份的写入仍被接受(黑板无门),author 记
unknown——不编造身份;
decisions 写入是读-算 seq-追加(appendFile,不重写正文):并发追加最坏只是两条同 seq,不会丢行。
推荐起手的 discipline.md(把协作纪律写成条款,而不是靠每个会话自觉 —— 四条都只用今天已有的能力,零新增机制):
1. 派活即落账:派活的人当场把这件事写进任务台账(tasks.md,kind=plan),并拿到插件分配的任务号(t-<n>)。
2. 回报必带任务号:回报消息的 meta.ref 填对应任务号 —— 主会话不必记得全部计划。
3. 记不清就读台账,不靠回忆。台账是路标,不是事实。
4. 验货查文件与证据,不查台账:关于「做完没有」的结论,永远以各会话的原话与证据为准。
台账推荐用法(tasks.md 已实施,§9 第一批 —— 派活落账 / 回报带 ref):
- 派活即落账:派活的人当场调
team_link_team_append(file="tasks", kind="plan", line="让 worker-b 复核 §3 的行号"),把它返回的 t-<n> 写进派发消息的 meta.ref。计划从此住在文件里,压缩够不着;主会话失忆后一次 team_link_team_read 就能读回「每个任务的最新主张」;
- 回报带
ref:回报消息的 meta.ref = t-<n>(字段今天就有,≤16 字符、已校验、已渲染在消息 banner 首行)——主会话不必记得全部计划,看 ref 就知道这是哪件事的更新;
- 其余动作各追加一行:
claim(接活)/ done(声称完成)/ block(卡住、需要授权)/ dispute(存疑)—— 都用 task="t-<n>"。反驳也是追加一行,不是改旧行(台账只能追加,谁也覆盖不了谁);
- 边界:台账是路标——验货仍然查文件与证据。读面给的「最后主张 / 未消解存疑」是派生读数,而工具绝不替你裁决:主张照原话走消息,存疑靠会话之间说清楚。
这四条的由来与完整论证(含只追加台账的原始层/投影层之分)见 docs/team-ledger-and-mode-design-2026-09-26.md §1 与 §2.5。
五、团队换届 rotation
团队换人不是「改个 current」:新任会自动继承一条绕过两道批准门的免确认通道(pairs,连接收方的显式 reject 策略都覆盖),所以交接被拆成两个阶段 + 一次性令牌 + 域限定迁移 + 可回退的临时信任。
sequenceDiagram
autonumber
participant R as 现任(旧任)
participant P as 插件
participant S as 继任者
participant W as 团队其他成员
participant U as 用户
R->>P: team_link_rotate action=prepare(successor)
P->>W: [rotation-freeze] 固定冻结清单(停哨兵/后台 job)
P-->>R: 令牌 T(30 分钟有效,明文只此一次)
R->>S: 交接 prompt(含 T,内容由模型起草)
S->>P: team_link_rotate action=claim(token=T)
P->>U: 单个对话框:逐项勾选要迁移的 pairs
alt 在场并提交(ratified)
P->>W: pairs 域内迁移(正式)+ [rotation-done 已批准]
else 无人值守 / 超时 / 无确认服务
P->>W: pairs 域内迁移(provisional, 24h)+ [rotation-done 待批准(24h)]
U-->>P: 24h 内在设置里把该 pair 的 provisional 置 false → 转正式
P->>W: 24h 未批准 → 删除迁移出的 pairs + [rotation-expired]
end
P->>W: 30 分钟无人认领 → [rotation-cancelled](旧任仍为 current,解除冻结)
prepare(Phase A,只能由该角色的现任会话发起)
- 前置:
exec.agent.id === roles[role].current;用户路径是设置 UI,不是工具调用;
- 速率限制:同一 team+role 在 10 分钟内已有 pending 或刚完成过一次换届 → 拒绝(防换届风暴);
- 令牌:
randomUUID(),绑定三元组 (team, role, successor),30 分钟有效、成功认领即作废;
- 快照:把 prepare 时刻的
pairs / trustedSenders / rememberTargets / roster 全量写进 rotationBackup(对称撤销的还原依据);
- 广播:向团队全部在任成员投递
[rotation-freeze] 冻结清单(停哨兵/后台 job → 确认无在飞动作 → 状态冻结回报 → 等待交接结果通知);
- 返回:令牌明文(唯一一次)+ 掩码形式 + 交接指引(30 分钟有效、「交接内容由模型起草,机制与判断分离」、上任首动作建议
/goal resume 或建新 goal、错峰默认、以及 successor:"auto" 的自建路径——§3.6.4 的手工兜底始终保留,见下一节)。
claim(Phase B,只能由 pending 指定的继任者会话凭令牌发起)
- 前置:
exec.agent.id === pending.session(且 pending 未过期);令牌精确匹配且三元组一致;
- 一个对话框(多选):把全部「退役者↔同 team 成员」的候选 pairs 列成一个多选问题——勾选 = 迁移,不勾选 = 随退役清理(今后该对端走正常首问门)。对端在团队外的 pairs 不进候选但会在 detail 里被点名(透明)。候选为空时不弹框:没有可批准的东西,落定后状态词记
无待迁移对 而不是「已批准」;
- 在场确认 = ratified;超时(3 分钟)/ 无确认服务 / 对话框失败 = 无人值守路径:全部域内候选以
provisional 迁移并开 24h 回退窗口;
- 迁移与撤销:
- 迁移 = 删除退役者的旧 pair,新建
{a: 新任, b: 对端, provisional, expiresAt};
- 对称撤销 = 退役者持有的 pairs(全部)、
trustedSenders 中指向它的项、rememberTargets 中指向它的项,一并清除——退役会话可能还活着,不吊销就是永久保送;
- 落盘顺序:迁移 + 落定(
current / 版本史 / 迁移标记)在同一笔写入里完成,随后才清除 pending。因此「pending 还在」且「current 已是继任者」只可能意味着上次 claim 没收尾;幂等判据是 current === pending.session,而不是「迁移清单是否非空」(域内没有候选、或每个候选对端都与继任者本已配对时,本次迁移本就不新建任何记录);
- 幂等:同一令牌重放 → 返回既有迁移清单、不重复迁移、不重复改信任数据,只补做收尾(清 pending、补写
roster.md 镜像、重发一次 rotation-done 避免 worker 因崩溃卡在冻结里);成功之后令牌作废;
- 广播
[rotation-done]:已批准 / 待批准(24h) / 无待迁移对。第三个词用于域内没有任何候选 pair 的换届:既然没弹过确认框,就不能谎称「已批准」。落定时该状态词一并记进 roles[].rotationStatus,重放直接读记录值——它不由 provisional / 迁移清单重推(「对话框答了但一条都没勾」是 ratified 且无迁移、无回退窗口,正是重推会失真的窄边缘)。
未批准路径与到期清扫
| 对象 | 期限 | 到点行为 |
|---|
pending(令牌) | 30 分钟 | 清除 pending + 广播 [rotation-cancelled]:旧任仍为 current、令牌过期未认领、解除冻结。若 current 已是继任者(上次 claim 只差最后一步收尾),则静默清除 pending、不广播——换届其实已经发生,「旧任仍为 current」与事实相反 |
provisional pairs | 24 小时 | 删除迁移出的 pairs + 版本史追加 provisional 未批准过期 + 广播 [rotation-expired]:新任保持 current(换届事实已成立,降格要用户显式操作),信任回退为正常过门。若该窗口迁移出的 pair 都已在设置 UI 补批准为正式通道(或本次迁移本就没新建通道),则窗口静默关闭:不记版本史、不广播——没有回退发生,「迁移的 pairs 已删除」会是假话 |
清扫有两个触发面:看门狗的同一个巡逻定时器(定时器随注册存在——一个看门狗都没注册时它不跑),以及惰性检查(每次 roster 触碰 / rotate 调用 / team_read 读取都会先扫一遍)。换言之:只要团队里还有人读黑板/动 roster/走换届,过期 pending 与过期窗口就不会漏;三者都没人碰时,冻结状态要等下一次触碰才解除。
投递侧另有独立守门:expiresAt 一到,该 pair 立刻不再算配对(pairRecordBetween 视过期 provisional 记录为无 pair),投递当场退回正常双门——不必等清扫真的删掉那一行。过期 pairs 的删除也不依赖角色记账:doomed pairs 的计算与删除排在角色记账判据之前,所以即使用户在设置 UI 手删了 roles[].provisional 窗口(甚至整行角色/团队),过期通道照样会被清除。
补批准入口是设置 UI:24h 内把该 pair 的 provisional 置为 false 即转正式(唯一口径——不要用删 expiresAt 的方式:那会留下 provisional: true 且永不过期的记录,可见面文案与通道事实不一致);已回退后不再复得。补批准只改 pair、不改 roles[].provisional 窗口——窗口到点时若域内已无待回退的 pair,它就静默关闭。
自动换届(③):successor:"auto"、交接文档与 /team_rotate
换届机制(令牌 / 域限定迁移 / 对称撤销 / 24h 回退 / 到期清扫)早已发布;③ 消掉的是它唯一的人工前提——继任者会话原本必须先在壳里建好。两条入口:
| 入口 | 谁发起 | 做什么 |
|---|
team_link_rotate action=prepare, successor:"auto"[, handoff] | 现任模型(工具调用) | 自建继任者 + 写交接文档 + 铸令牌 + 广播 freeze + followup 把令牌与交接正文投给继任者 |
/team_rotate <role> [team=<name>] | 人类(命令) | 校验发起者确为该角色现任 → 向该会话 followup 一条指令(起草交接并调用上面的工具)→ 返回摘要给 UI |
/team_rotate 只做机制:CommandResult 的文本由派发它的 UI 渲染、进不了模型上下文,所以命令无法自己产出交接正文——那必须由模型起草。命令也不建任何会话(零创建、零 pending、不改信任);真正的新建 + 交班在工具侧,且必过一次确认框。命令与工具入口并存,工具入口是兜底。
确认(§11.4.1):新建会话 + 交班是爆炸半径大的动作,工具路径在建会话之前必过一次 userQuestions.ask,框内写明新建几个会话(successor:"auto" 每次恰 1 个,§11.2 逐角色)、新会话 id、cwd、模型情形(本路径没有 model/provider 参数 ⇒ 两半都由插件解析并带上宿主缺省模型选择**(agentDefaultModel.currentSelection(),§10.2.2 模板第 ④ 步——不是「新会话继承默认选择」,那条判断已被真机推翻,见 DEFECT-3);preset 取宿主缺省,缺省也解析并挂载,见 §10.2 的 DEFECT-1 修复)、保守成本口径、将触碰的信任面、取消 = 零副作用**。无确认服务 → fail-closed(不建、不铸令牌、不广播 freeze)。
自建继任者(§11.4.2):与 /team_session 完全同源——根会话(meta 只放 cwd / agentPreset(缺省也解析出的那一个),origin / parentSession / delegationDepth / parentAgent 一律不写)、id 形如 team-link-<team>-<role>-<uuid8>、从插件根 ctx 创建并由插件持有 handle(所以 §10.2.5 的生命周期代价同样适用);cwd 取发起会话的工作目录,取不到就拒绝——绝不用宿主进程的 process.cwd() 顶替。这一条是 DEFECT-1 影响面最大的地方:继任者与 worker 走的是同一个 createRootAgent(原先只叫 buildTeamSessionCreateOptions),所以「缺省不挂 preset」在换届路径上意味着「令牌投给了一个跑不起来的持钥者、而旧任已冻结」。DEFECT-2 的影响面同源:同一个函数也负责把会话挂进工作区(workspaceRegistry.create → meta.cwd = workspace.path → attachSession),所以不修则换届后的新协调者在侧边栏里同样找不到。
交接文档(§11.4.3 / §11.9.6):插件写 <workspace>/team/<name>/handoff-<role>-<YYYYMMDD-HHmmss>.md,三层结构——
| 层 | 谁写 | 内容 |
|---|
| 头 | 插件 | schema / team / role / 前后任 id / preparedAt / claimedAt / 令牌掩码 / rotationStatus / 完整性判定 |
| 事实段 | 插件 | 与 claim 返回文案同一事实源(同一个构造器渲染迁移/未迁移/对称撤销行,同一常量给 freeze 正文,同一函数给 provisional 回退窗口)——「不可两处口径」 |
| 正文 | 旧任模型 | 五个硬节 + 软节 |
正文的五个硬节逐字为 mission / in-flight / commitments / unknowns / task-and-goal(软节 first-actions / team-map / conventions)。缺项阶梯(验证点在任何副作用之前):
| 情形 | 处置 |
|---|
successor:"auto" + 正文缺失/空 | 拒绝于工具入口:零建会话、零令牌、零 freeze,错误文案给出五硬节脚手架 |
| 正文在场但硬节缺 / 空 | 拒绝并点名缺哪些节 |
| 软节缺 | 放行 + 警告(头部记录) |
显式 successor + 无正文 | 放行 + 警告(那个会话有自己的生命与上下文,M4 现语义不收紧) |
| 文档写入失败(auto 路径) | abort-before-prepare:不铸令牌、不广播 freeze,已建会话如实报为孤儿 |
| claim 时 | 不复验文档(它是审计件,不是执行件) |
诚实原则(写在契约里):插件只把关结构(节在场 / 非空 / 有界),不把关内容质量——五个节全写 TODO 也能过。presence 门防遗忘,不防敷衍。
投递(§11.4.5):令牌明文(只此一次,此后一律掩码)+ 交接正文 + 「立即 claim」用 followup 发给继任者(不是 inject——任务需要驱动),消息 source 仍恰三成员 {kind, form, senderSessionId}。
claim 一步未省(§11.6 红线):自动化不得跳过 claim / 令牌 / 域限定迁移 / 对称吊销——信任迁移仍要继任者在 claim 时逐项勾选(无人值守则 provisional + 24h 回退),本次确认框不迁移任何 pairs。
失败与孤儿(§11.5):① 令牌 30 分钟未认领 → 既有 rotation-cancelled(旧任仍为 current、解除冻结),并额外点名本次插件自建的那个继任者(「仍存活但未认领,可收编或关闭」)——否则它会变成一个没人知道的孤儿;② 崩溃在 create 与 prepare 之间 → 复用 §10.2.6 的 pending-create 意图(TTL + 启动清扫 + 可收编清单);③ 部分成功不回滚:会话已在盘上、可能已被打开,一律如实报告。
写文档的时机(一处需要读者知道的设计张力):§11.9.6 要求「写文档失败 → 不铸令牌、不广播 freeze」,而头部又要带令牌掩码、事实段要带 freeze 投递摘要——后两者在写文档那一刻还不存在。本实现取前者严格成立:令牌先在内存里铸出(只为拿到掩码)→ 写文档 → 走 prepare(令牌落盘、freeze 广播、投出);写文档失败时那次铸出的令牌从不落盘、从不投出、prepare 不进入,可观测意义上仍是「零令牌、零 freeze」。事实段里 prepare 之后才产生的类目(迁移清单、对称吊销明细、freeze 逐目标结果)如实标为「待 claim 落定 / 本文件先于广播写入」,落定后以 claim 返回为准——文档不追写。
现任无人时的恢复(§11.9.3–§11.9.5):诊断面 + team_link_recover
卡住的是身份与信任面,不是全瘫(§11.9.1):黑板写(team_link_team_append)与跨会话投递(team_link_send)都不过 writerGate,团队照样能说话;写不进去的是 roster 变更、换届与建队登记。硬死锁只有一格:policy.writer=coordinator(默认)且死的是 coordinator——死 worker 时活协调者可以 retire + set-role 重建(丢信任拓扑但不死锁),writer=any 的队任何会话都能 set-role 补位。
诊断面不新增任何持久状态(§11.9.3):liveness 是进程内、瞬态、观察者相对的事实,落盘即陈旧(一次插件重载会把全体插件自建会话同时写成 dead),而且刻意空缺与死亡空缺必须可分。于是两个词只在读取时派生、只在活着的读面出现:
| 词 | 含义 | 第一动作 |
|---|
vacant | current=null(retire 或用户经设置 UI 造出的显式表达) | 设置 UI 指定现任 / team_link_recover action=reappoint |
seated-dead | current 非空但 agents.get(current) === undefined(悬空指针) | 在侧边栏重新打开那个会话(同 id 复活,信任零手术) |
出现的地方:三道门(writerGate / rotateGate / retireGate)的拒绝文案(gate 本体保持纯函数,由有 ctx 的工具层富化——今天这道文案对死现任是误导性的)、team_link_roster get 的现任行、以及启动清扫新增的一行「各团队 current 无活代理的角色」。roster.md 镜像刻意不加:它是落盘文件,把读数瞬间烙进持久物会立刻陈旧。
恢复工具 team_link_recover(§11.9.4):恰两个封闭动词。
| 级 | 动词 | 机制 | 何时用 |
|---|
| L1 | revive | ctx.agents.resume 复活同一个会话(身份不变、roster 不动、信任零改动),并按宿主自己那条 resume 路径的时序把这具代理组装齐:模型选择(agentOptions)+ 这个会话自己存下来的 preset 组装源(工具面与人设)——组装不出来就拒绝恢复(零 resume、零写入),绝不留下一个第一回合就死在 prompt variable "{{model}}" has no value 的代理(DEFECT-5) | 死亡绝大多数是重载/重启假象 |
| L2 | reappoint | 人类对话授权的 prepare:候选 = 插件从本队活成员算出的成员 ∪ 常驻的「自建继任者(新建会话)」 → 人类勾选 → 铸令牌绑定 (team, role, successor) → 逐字走既有 prepare(rotationBackup 快照 + rotation-freeze 广播)→ 继任者凭令牌 claim(claim 一步不改,不新增令牌类型) | 现任不会/不应再回来 |
L1 的适用域(revive 只对插件自建会话开放):resume 的 ownerCtx 是插件根 ctx,复活后该代理的运行时所有权归插件、插件卸载即拆;对人类自建会话做 revive 会把它的生命周期从 UI 转给插件,比现状更差——所以人类自建的会话只输出深链指引(「请在侧边栏打开」)。插件自建的判定是两半:本进程仍持有它的 AgentHandle(teamSession.hasHandle),或它的 id 合 §10.2.2 的文法 team-link-<team>-<role>-<uuid8>——后半是跨重载的那半,而重载恰恰是 L1 最要紧的时刻(重载后 handle 表按构造就是空的)。resume 不可用(无 factory / 无 sessionPersistence)或抛错 → fail-closed 报告,零改动。
八条硬约束(§11.9.5,每条都落成会红的断言):① 动词封闭(只 revive/reappoint),不接受任意 roster 字段写入、不改 policy.writer;② attended-only——必须有人在对话框里点一下,刻意不设 provisional / 无人值守变体,无确认服务即 fail-closed(与 claim 的不对称是刻意的:pair 迁移可被清扫自动回退,incumbency 不可);③ 候选由插件计算(活成员 ∪ 常驻合成候选「自建继任者(新建会话)」),模型只传 team(+可选 role),不得指定继任者 id(对话框选项就是候选,答案按 label 回读——合成候选连 id 都还没有,它由插件在人类勾选之后才铸);④ revive 只绑当前 current(不存在「复活任意历史会话」的动词);⑤ writerGate 原样不动;⑥ 绝不把 policy.writer 降级为 any 当作「修复」;⑦ 限速(与换届同一 10 分钟窗口)+ 三处留痕(role 行的 recoveries 备注 recovery(<verb>, vacant-due-to-death, requester=…) / roster.md 镜像 / decisions.md 追加——黑板无门,死锁下也能落账);⑧ 进入即先跑既有过期清扫。另有一条不编号的发起域(§11.9.5,见下「发起域」段):发起者只能是本队现任成员或该角色最近一任前任——它不属于这八条,也不改任何一条。
写时复检(TOCTOU):对话框横跨任意长的人工等待,所以条件由宿主观测、不由调用方主张——本轮跑两次 agents.get(current) === undefined(对话框弹出时、落笔前各一次),现任已复活则中止「现任已复活,无需恢复」;候选在确认期间死亡同样中止(否则原地再造一个死结)。
角色面(两个动词都受理任意角色,差别在动词语义上):revive 受理任意角色(0.3.9 批次 2 §4.2 (a) 放开了原来的 coordinator 窄域)——重载拆掉的是所有插件自建会话,不只 coordinator 的;而 §11.9.1 只论证过「死的 coordinator 必须可救」,从未论证「死的 worker 不许救活」,那条窄域是范围最小化选择、不是安全属性。放开它不移动红线一寸:revive 是身份不变的操作——不写 roster、不铸令牌、不动 pairs/trustedSenders/rememberTargets、也不碰 policy。所有权门原样不动(只对插件自建会话开放)。reappoint 也受理任意角色——授权从不源自 coordinator 身份(唯一来源是对话框里人类那一下点击)。两者的差别在动词上:revive = 同一个会话复活(零写),reappoint = 换人(令牌 + 快照 + 冻结,走完整 M4)。不带 role 时只输出诊断(每角色一行:现任 + 活性 + 在飞令牌),零副作用。
选「自建继任者」时发生什么(§4.2 (c):链路的每一步都与既有机制同源,收尾的投递与 successor:"auto" 同一条):候选列表常驻一项「自建继任者(新建会话)」——宿主可编程创建会话时它始终在列,不是「只在没有活候选时才出现」(事故当场的候选长度是 1 不是 0,条件式触发根本不会激活)。能力闸门只约束这条支路,不在动词入口(0.3.9 批次 4 收窄):宿主没有 agents.create ⇒ 弹框照开,只是候选里不出现「自建继任者」(只列活成员——活成员改任不需要该服务),仅当连一个活成员候选都没有时才 fail-closed 报告且零弹框。选中后的链路一步不新造:按 §10.2.2 铸 id(team-link-<team>-<role>-<uuid8>,与 /team_session、successor:"auto" 同源)→ 交接文档由插件从 roster 事实生成五硬节(mission / in-flight / commitments / unknowns / task-and-goal;读不到的项如实标未知,不编造——task-and-goal 一节填的是 goals 服务的读数,读不到才标未知)→ prepare 逐字跑 → 审计行 verb=reappoint → 用 followup 把令牌与交接正文投给刚建出的继任者(与 successor:"auto" 的投递同一条:不是 inject,任务需要驱动;消息 source 仍恰三成员)。代价如实声明:继任者是空上下文的新会话,历史不迁移;信任靠它本人 claim 时逐项勾选迁移。收敛性红利:此后每次恢复的终态都是插件自建 id,而插件自建 id 正是 revive 的适用域。
发起域(§11.9.5 的「发起 ≠ 授权 ≠ 复权」):发起者只能是该团队现任成员或该角色最近一任前任(发起权不依赖信任、只依赖身份资格;旧任上下文最完整,「回聘旧任」本就是最自然的恢复)。域外的活会话被拒绝,拒绝文案点名现任成员集合、该角色的前任与调用会话,并指出设置 UI(R2 级,用户在那里是超级写者)仍是永远可用的出口——挡住的只是会话路径,不是人。代价如实声明:不属于本队的活会话(人类随手开的、没登记进 roster 的会话)不能发起恢复,这是有意的收窄;带 role 的诊断读态不受此限(它零写入)。
内部通知
四种通知(rotation-freeze / rotation-done / rotation-cancelled / rotation-expired)的正文是插件常量:只有团队名、角色名、会话 id、读数时间、状态词被插值,且每个插值都先过单行清洗——模型的 note、消息正文一律进不去(与看门狗 tick 同一条红线)。
- 免发送方审批(正文是插件常量,不是模型可注入的载荷),但接收方的 inbound 策略与
blockedSenders 照旧生效;
- 发送方身份:
prepare / claim 用发起该动作的会话;清扫类通知用「该角色现任」(取消时是旧任,回退时是继任者);现任空缺且无继任者时通知不发并如实报告,绝不编造发送方;
- 收件人并集:
rotation-done / rotation-cancelled 的收件人 = 团队在任成员 ∪ rotationBackup.roster 里该角色当时的旧任(仍存活且不是继任者本人时)——退役者在落定之后就不再是任何角色的 current,不并进来就永远收不到「换届完成」;
- 内部通知一律不弹确认框:通知在工具调用或巡逻里逐个串行投递,接收方策略为
ask 时该目标记一行「不弹确认框」并跳过——几个 ask 成员各弹 3 分钟就会吃掉整个工具预算,弹框也会把巡逻阻塞 3 分钟;
- provisional 可见面(不下放给
source、也不加 meta 字段):send 经 provisional 通道投递的返回文案后缀、rotation-done 的状态词、roster get 的 pending/provisional 行、list_sessions 会话行上的「provisional 配对 N 条」标记。
换届记账的结构(teams 键内)
roles:
- role: coordinator
current: session-new # 换届后 = 新任
pending: # 仅在 prepare 与 claim 之间非空
session: session-new # 继任者;只有该会话能 claim
token: 1a2b3c4d-... # 一次性令牌(一切渲染都是掩码 tok-1a2b…9f0e)
team: night-shift
role: coordinator
createdAt: 1700000000000
expiresAt: 1700001800000 # = createdAt + 30min
migratedPairs: [] # 上次 claim 已迁移的通道清单(重放时回显);幂等判据不是它是否非空
note: 交班 # 可选:随 pending 带到 claim 的版本史备注
rotationAt: 1700000000000 # 最近一次「完成」的换届(速率限制的另一半)
rotationStatus: 已批准 # 最近一次落定换届的状态词;重放直接读它
provisional: # 未批准的 24h 回退窗口;ratified / 已回退时为 null
at: 1700000000000
expiresAt: 1700086400000
session: session-new
rotationBackup: # prepare 时全量快照(撤销依据,永不自动清理)
at: 1700000000000
pairs: [...] # prepare 时刻的全部 pairs(副本)
trustedSenders: [...]
rememberTargets: [...]
roster: {...} # prepare 时刻的团队记录快照(含旧任)
错峰默认:先换协调者 → 稳定 → 再换 worker,任何时刻保留一个活记忆;一次全换之前必须先跑冻结清单并把交接文档落盘。
六、策略配置
设置命名空间 team-link(设置 UI 可直接编辑)。
落点二选一,一个时刻只写一处:
- settings 服务在场 ⇒ 状态只在 settings 里,本插件不写那个文件(读不读见下一段:命名空间为空时会读进来折叠并入,已有数据时连读都不读);
- settings 不在场 ⇒ 同一份状态写在
<DSH_HOME 或 ~/.dsh>/team-link/policy.json(路径优先取 DSH_HOME,缺失才退回 ~/.dsh;落全局目录而非工作区,团队名是全局寻址的);文档形状 {schema, savedAt, foldedAt, policy},policy 与下表的键集一一对应(无损)。写入是原子的(policy.json.tmp → fsync → 覆盖),写失败保留内存态并告警、下次写入重试;
- 两个落点都没有 ⇒ 进程内记忆 + 一行告警(最后兜底)。
policy.json 读不出来 / 不是合法 JSON / 没有 policy 对象时:插件不覆盖那份文件(写入每次都直接跳过并说明「未落盘、该文件未被覆盖」),状态暂存进程内存 —— 修复或移走该文件后重启 DSH 即可恢复(每个进程只读一次文件,改好了不重启不会自动生效)。
settings 之后再挂上时,文件里的内容会折叠并入 settings(服务成为落点),并立刻把合并结果写回文件、盖上 foldedAt;此时文件与服务都有数据,以 settings 为准,文件那一份原样归档成 policy.superseded-<时间戳>.json 并留痕(不做静默合并)。回写挂在 attach 后置链的末尾(旧命名空间迁移之后)—— 写早了,文件会缺迁移进来的那一份信任数据。
settings 从一开始就已在场时同理:命名空间若还是空的,文件里那一份照样折叠并入(不会因为「挂得早」被跳过);命名空间里已有团队则一律 settings 为准,文件那一份连读都不读。
怎么确认它存在哪:team_link_roster action=get 与 team_link_status 的首行给出三态之一 —— 存储:设置服务(team-link 命名空间) / 存储:文件 <绝对路径> / ⚠ 存储:仅进程内存(重启即失)—— 原因:…(含 文件损坏(不覆盖;请人工处理) 这一项,见上一段的处置办法);状态卡里团队清单那一段还会再说一遍来源(来源:设置 team-link 的 teams 键 / 来源:文件 <绝对路径> 的 teams 键 / 来源:进程内存的 teams 键(重启即失)),跟着实际落点走。
命名空间的键集九项(FR-9 起,PolicyConfig 声明面同批更新):
| 键 | 类型 | 说明 |
|---|
receiveMode | ask / accept / reject | 默认 ask:逐条确认 |
trustedSenders | string[] | 免确认接收的发送方会话 |
blockedSenders | string[] | 拒收并屏蔽(优先级最高,压过配对) |
rememberTargets | string[] | 发送方免确认的目标会话 |
pairs | {a, b, createdAt, provisional, expiresAt}[] | 双向免确认配对通道。provisional: true = 由换届在无人值守路径上临时授予,expiresAt 到期未获批准即自动删除并回退为正常过门;正常配对 provisional: false、expiresAt: 0 |
watchdogs | {id, team, watcherSession, targets, silentMinutes, intervalMinutes, expiresAt, createdAt}[] | 跨会话看门狗注册(到点自动清理;手改时缺字段的条目会被丢弃,不会让整个命名空间失效) |
sections | {role: boolean, resources: boolean} | 总开关(FR-9,缺省都开):role=false ⇒ 不注册角色/宪章段(工具返回面的角色重钉同关);resources=false ⇒ 资源行三处全缺(提示段 / 状态卡第 ⑧ 段 / 工作台摘要脚注) |
teams | {name, createdAt, workspace, policy:{writer}, roles:[{role, current, pending, rotationAt, provisional, rotationStatus, history}], rotationBackup, mode, leadSessionId}[] | 团队 roster 与换届记账。mode 是形态(sessions 默认 / agent-team,闭集外的值读时降级为 sessions 并在读面留痕);leadSessionId 是 agent-team 档的 Lead 指针(空串 = 未指定,读面标「Lead 未知」;成员名册一行都不落 settings)。name 必须 [a-z0-9-]+(它是黑板目录的路径段);workspace 是团队首次创建时捕获的会话工作目录、也是黑板根;手改时非法团队名/无名角色会被丢弃 |
七、服务获取与留痕(0.3.7 的关键修复)
这一节只留使用者要用的契约;那次故障的完整调试历程(病灶代码、逐条修法、三个配套细节、红线与回归锁)已移到 docs/verification-log.md——README 是说明书,不是实验记录本。
契约:本插件对 settings / webServer / connection / commands / agentPresets / workspaceRegistry / agentDefaultModel / sessionTitle 等运行期服务一律走可选获取(模块级 inject 恒为 4 项,见 §八)——服务缺席或迟到时,受影响的只是一项能力,插件本体与其余能力照常工作;每一次降级都留一行具名 warn(点名缺了哪个服务、后果是什么)。分界:在决定**「会话能不能跑」的地方(preset 源、模型选择)改为 fail-fast——宁可不建,也不建一个跑不起来的会话;只决定呈现面**的地方(标题、卡片)则降级不阻断。
八、兼容性与字符串安全
DSH 0.1.5 会话格式迁移
0.1.5 的会话日志迁移(V0→V3)逐条校验每条 message 的 source:白名单外的 kind、或成员多一个少一个,都会让整份会话日志拒绝迁移(SessionFormatUnsupportedMigrationError,表现为「这个对话打不开」),且拒绝时不会破坏原始日志。
旧版本插件写的是 kind: "team-link",正是这种会被拒的形状——所以 0.2.3 起改用上游自己的中继形状 { kind: "agent-message", form: "relay", senderSessionId }(白名单见 @deepseek-ai/dsh-session-format-v2-to-v3 的 SOURCE_KINDS;agent-message 的成员集合被锁死为恰好三项,多余信息只能进正文)。
已经在旧日志里的 kind: "team-link" 事件改不回来(kind 已烙在已发布的事件流里),需要外部工具改写原始日志或在导出前修复——修复方向即把 source 替换成上述三项形状。
字符串安全:孤立代理项
半截 emoji(孤立代理项,例如单独的 U+D83D)不是显示瑕疵。DSH 把工具结果逐字放进下一次模型请求的 JSON 体,非法 UTF-16 会让那次请求直接以 400 INVALID_REQUEST 失败;而这段文本已经写进调用方会话历史,于是该会话此后每一条消息都以同一个 400 失败(连 test 四个字母也一样),INVALID_REQUEST 又不在重试白名单里——会话永久不可恢复。
本地扫描 882 份会话日志,只有 5 份含真正的孤立代理项:走 deepseek-official 的 4 份全部在下一次请求当场死亡(0 条成功回复),走 qax 的 1 份存活。诚实交代:没有做重放实验(没有构造含孤立代理项的请求去打 API),这是观测相关性;但机制无争议,且「按码点截断」本来就是这两个函数应有的写法。
- 根因:
preview() / truncate() 用 slice() 按 UTF-16 code unit 截断,切割点落在代理对中间时只留下一半。凶器是列表工具的主题预览 preview(topic, 90):emoji 恰好压在第 89 个 code unit 上;
- 修法一(不再制造):两个 helper 改为按码点截断(
[...str] 迭代),限额与纯 BMP 文本的渲染结果完全不变;truncate() 的「已截断 N 字符」计数口径随之从 code unit 变成码点(更正确:原来一个 astral 字符被算作 2 个「字符」,却只输出一半);
- 修法二(纵深防御):新增
wellFormed() 消毒所有对外字符串——列表工具正文、export 的 md 与 JSON、send 的两处批准提问正文、投递到目标会话的 banner(否则毒的是接收方会话)、拒绝文本里回显的目标 id、深链注入的会话快照、team_link_send 的结构化回执(tool/result.meta,见 §10.1——它不走 output.render,是唯一一条绕过模型可见出口的持久化路径),以及三个工具 output.render 这最后一道模型可见出口;
- 客户端同理:
shortSessionId() 的 slice 改为按码点切,卡片正文、发送方 id 与委托回退文本渲染前都过一遍 wellFormed()。这条路径只影响显示(浏览器 DOM 的 USVString 转换本就会把孤立代理项变成 U+FFFD,且不会再进入模型请求),属显示层加固;
- 验收不变量:本插件返回的字符串里永远不出现孤立代理项——把 emoji 摆在任意切割位置上,输出要么完整包含它、要么完整丢弃它。回执卡另有一条同样口径的不变量(差异审计 F2):卡内每一个字符串成员都过
wellFormed(),判据是 JSON.stringify(card) 上无孤立代理项。
九、安装
宿主版本支持(先读这一节)
| DSH 宿主版本 | 状态 | 建议 |
|---|
| ≥ 0.2.0-rc.1 | ✅ 本版实测通过(2026-09-29 真机:0.2.0-rc.1 桌面版加载成功 · 10 个 team_link_* 工具在场 · team_link_status 实调返回完整状态卡) | 用 0.4.2 及以后 |
| 0.1.0-rc.6 – 0.1.7-rc.x | ⚠️ peer 区间仍声明支持(^0.1.x 两个历史分支未撤,装得上),但本版含 0.4.0 之后累积的 10 个条目、实现相对 0.4.0 有实质改动(lib/index.js + lib/client.js 合计 1188 增 / 70 删;lib/index.js 的 sha256 071f3269… → 860d51d7…),而本轮只在 0.2.0-rc.1 上实测 | 保守起见留在 0.4.0 |
低于 v0.2.0-rc.1 的使用者:请使用 0.4.0 版(见 CHANGELOG.md 的 0.4.0 — 2026-09-27 一节)。本版(0.4.2)的 peer 区间虽然保留着 ^0.1.x 分支、在 0.1.x 上仍然装得上,但 0.4.2 的实现相对 0.4.0 有实质改动,而本轮只在 0.2.0-rc.1 上实测过 ⇒ 老宿主上我们不背书。
为什么这张表是硬约束、不是建议:DSH 0.2.0-rc.1 起,加载器在挂载任何插件之前先跑一道兼容性闸门——对 bundle 的 peerDependencies 里每一个 @deepseek-ai/dsh / @deepseek-ai/dsh-* 条目做 semver.satisfies(运行时版本, 区间, { includePrerelease: true }),任一条不满足就把整个 bundle 静默丢弃(只往宿主 stderr 打一行,GUI 里毫无提示,症状就是「插件的工具凭空消失」)。机制细节见下面「依赖声明:宿主包一律走 peerDependencies」一节。
方式一:本地目录 + 热装配(开发常用)
依赖通过 junction 复用 DSH 检出目录的 node_modules(免下载):
$dir = "<克隆目标目录>" # 换成你自己的路径,例如 D:\dsh-plugins\dsh-team-link
git clone https://github.com/shenhuanageshei/dsh-team-link.git $dir
cd $dir
$nm = "$(Get-Location)\node_modules"
$dsh = "<DSH checkout 路径>\node_modules" # DSH 检出目录下的 node_modules
New-Item -ItemType Directory -Force "$nm\@deepseek-ai" | Out-Null
foreach ($p in @('schemastery', '@deepseek-ai\cordis', '@deepseek-ai\dsh-session-reference', '@deepseek-ai\dsh-tools')) {
New-Item -ItemType Junction -Path "$nm\$p" -Target "$dsh\$p" | Out-Null
}
然后用 dsh-super-injector 热装配进运行中的 shell:
dev_install_package { dir: "<你的目录>/dsh-team-link", profile: "web" }
或手动装配:profile package.json 的 dependencies 写 "dsh-team-link": "link:<本目录>",dsh.profile.bundles 数组加入 "dsh-team-link",重启 shell 生效(bundle 层不会被 HMR 重载——这一点在 0.3.7 的验证里被实测确认过)。
改完代码怎么让它生效(link: 装配)
推荐接线 = link:(junction 直指工作目录):DSH 加载的就是你正在改的那份代码,改完只需重启,没有任何拷贝步骤:
// profile 的 package.json
"dependencies": { "dsh-team-link": "link:<本目录的绝对路径>" },
"dsh.profile.bundles": [ /* … */ "dsh-team-link" ]
并让 <profile>\node_modules\dsh-team-link 是指向 <本目录> 的 junction(用方式一装出来的 profile 本来就是这样)。
为什么不要用 file::pnpm 会把整包拷贝进 <profile>\node_modules\.pnpm\dsh-team-link@file+...\node_modules\dsh-team-link,DSH 加载的是那份拷贝 —— 于是「改代码 → 重启」看着一切正常,跑的却是旧版(真机踩过:连重启两次都没生效,最后靠比对 lib/index.js 的 sha256 才揪出来)。若因故必须用 file:,改完代码要先刷新那份拷贝(pnpm install,或手动覆盖)再重启。
两种接线对宿主包的要求相同:@deepseek-ai/dsh-tools / @deepseek-ai/dsh-session-reference 由宿主提供,工作目录里只要能解析 schemastery 即可。
方式二:npm / bundle 安装
package.json 声明了 dsh.bundle.patch(cordis.patch.yml 只插入本包自身一行),可按 DSH bundle 插件的标准流程从包管理器安装。
注意:cordis.patch.yml 有意不插入 session-reference 行——DSH 已自带该服务(loader id 已存在),重复插入会导致 duplicate loader entry 启动崩溃。
依赖声明:宿主包一律走 peerDependencies
@deepseek-ai/dsh-session-reference、@deepseek-ai/dsh-tools、@deepseek-ai/dsh-client-locale、@deepseek-ai/dsh-client-ui-conversation、@deepseek-ai/dsh-client-ui-workspace、@deepseek-ai/dsh-api-session-controller、@deepseek-ai/dsh-api-workspace-controller、@deepseek-ai/cordis 都由 shell 提供,因此声明为 peerDependencies(八项)——纪律一句话:凡进 dsh.client.inject 的宿主包,一律同时进 peerDependencies(dsh.client.inject 的五项逐个都在上面这份名单里;shell 的 seed 模块 dsh-client-ui-primitives 两边都不进,理由见本节末)。只有与 shell 无身份耦合的纯库 schemastery 留在 dependencies。写成 dependencies 会在全新安装时拉进第二份同一个包(版本还可能落后于 shell)。
版本区间写成 ^0.1.0-rc.6 || ^0.1.5-rc.1 || ^0.2.0-rc.1 而不是单个 ^0.1.0-rc.6:npm 的 semver 规定「预发布版本只有在区间里存在同一 major.minor.patch 的预发布比较符时才算满足」,所以 ^0.1.0-rc.6(乃至 *)都匹配不到 0.1.5-rc.1——区间写窄了会在 0.1.5 上误报 unmet peer,甚至触发自动安装第二份。
这份区间同时是「能不能装上」的开关(DSH 0.2.0-rc.1 起,2026-09-29 实测):加载器在挂载任何插件之前先跑一道兼容性闸门,对 bundle 里每一个 @deepseek-ai/dsh / @deepseek-ai/dsh-* peer 做 semver.satisfies(运行时版本, 区间, { includePrerelease: true })——任一条不满足就把整个 bundle 静默丢弃:只往宿主 stderr 打一行,GUI 里毫无提示,症状就是「插件的工具凭空消失」。运行时版本取 @deepseek-ai/dsh-app-boot/package.json 的 version(不是桌面壳自己的版本号)。桌面版自动升到 0.2.0-rc.1 当天,本插件就因为区间停在 ^0.1.x 被整个跳过(^0.1.5-rc.1 展开是 >=0.1.5-rc.1 <0.2.0,不含 0.2.0-rc.1)。⇒ 每次宿主小版本换代(0.1 → 0.2 → …)都必须复核这份区间,否则升级即静默下线。
依赖的宿主服务
| 服务 | 用途 | 缺失时 |
|---|
session-reference | 深链解析 | 必需(在 inject 数组里) |
user-questions | 批准门 / 确认对话框 | 逐目标 fail-closed |
session-query | 列表 / 导出 | 必需(在 inject 数组里) |
agents | 投递 / 活性 | 必需(在 inject 数组里) |
goals | 活性行的 goal 状态 | 降级:显示 goal=? |
sessionProjections | 资源读数(stateOf(session, key)):提示段的资源行、状态卡第 ⑧ 段与判读行、工作台摘要脚注 | 晚挂取用(ctx.get);缺席 ⇒ 三处如实标「不可读(sessionProjections 缺席)」,判读行改说「读数不可得,不作判读」 |
agent.ctx.systemPrompt(agent 作用域) | 角色/宪章段与资源行的注册面(section({name, order, text}),order 取 getSectionOrder("TEAM_POLICY")) | 特征探测 + 降级:缺 section 或 getSectionOrder ⇒ 零注册 + 恰一行 warn,功能回退到「工具返回重钉」(不抛错、不影响任何会话的提示装配) |
settings | 策略落点(与插件自己的文件二选一,见第六节) | 晚挂取用;挂上之前状态写在 <DSH_HOME 或 ~/.dsh>/team-link/policy.json,两个落点都没有才降级为进程内记忆 + 一行 warn |
webServer | 导出下载路由 | 晚挂取用,降级为「无路由,工具照常」+ 一行 warn |
connection | 导出下载路由的平台信任栅栏(Host/Origin + 浏览器鉴权;requestRejection) | 晚挂取用,且与 webServer 成对齐备才注册路由:缺任一 ⇒ 不注册路由(绝不注册一条无门路由)+ 一行 warn(点名缺的是哪一个);导出工具照常 |
agentPresets | §10.2.2 创建会话时的 preset 解析与挂载(每个新建会话都必须有 persona-prefix 组装源) | 创建时 ctx.get 取用;缺席 ⇒ 每个新建会话一行 warn、会话照建(缺了组成源它可能跑不起来,DEFECT-1) |
workspaceRegistry | §10.2.2 模板的另一半:建/取工作区 + 把新建会话挂进它(侧边栏按工作区分组,没有这份归属就列不出该会话) | 创建时 ctx.get 取用;缺席 ⇒ 每个新建会话一行 warn(点名「未挂进工作区,可能不会出现在侧边栏」)、会话照建照驱动,只是不出现工作区归属(DEFECT-2) |
DSH 默认装配均有。
浏览器半边的模块声明(dsh.client.inject):除既有的 @deepseek-ai/dsh-client-locale 与 @deepseek-ai/dsh-client-ui-conversation,0.3.9 起另声明三项——@deepseek-ai/dsh-client-ui-workspace(会话导航 uiWorkspace.openSession)、@deepseek-ai/dsh-api-session-controller(ctx.sessions.list)、@deepseek-ai/dsh-api-workspace-controller(ctx.workspaces.list)。三者都随已发布的 web 组合恒在,且都是宿主包 ⇒ 与既有两个客户端模块同一条纪律:同时写进 dsh.client.inject(模块图)与 peerDependencies(沿用同款版本区间),防止全新安装时拉进第二份;服务本身是否可用仍逐项运行时判定:ctx.inject(["sessions", "workspaces", "uiWorkspace"], …) 齐备才注册侧栏入口,缺任一项 ⇒ 入口不注册 + 一行 warn(见 §二)。
纯 React 原子模块 @deepseek-ai/dsh-client-ui-primitives(弹窗用的官方 Modal、相对时间分桶、剪贴板写入)不写进 dsh.client.inject:它是 shell 的 seed 模块(与 react 同级由 shell 注入),官方插件如 dsh-better-sidebar 引用它时同样不声明。
十、测试
当前读数(实跑时点,一律取套件自报的那两行):node host-half.test.mjs → 1442(failed: 0);node client-half.test.mjs → 310(failed: 0)。2026-10-10 更新:批 1–3 + 真机修复落地后宿主套件为 1442;下面那段 1324 = 1314 + 10 是 2026-09-30 当时的构成说明,保留为历史:1324 = 1314 + 10:2026-09-30 DEFECT-5(revive 复活出来的 worker 第一回合必死 —— prompt variable "{{model}}" has no value;resume 面漏了宿主自己那条 resume 的三样:resumeSessionId + agentOptions + setup)新增 10 条宿主断言(含同批两轮代码评审修复轮补的 DEFECT-5 ⑤b、DEFECT-5 ⑦ 口径 与 组成面(mount 缺失·形状断层))+ 改写 1 条(U26 revive 原来的 Object.keys(resumeCalls[0]).length === 1 就是缺陷本身,改钉那三个键);红相=git checkout -- lib/index.js(夹具不动)⇒ host 1324 (failed: 11);绿相 host 1324 (failed: 0) / client 310 (failed: 0);原始读数见 docs/verification-log.md 的「2026-09-30 DEFECT-5」一节。)(1314 = 1310 + 4:2026-09-29 preset 租约补丁(同批代码评审发现:acquireScope 返回的是要释放的租约,原修复把返回值丢掉 ⇒ 每建一个会话泄一个)新增 4 条宿主断言(租约必须释放 / 失败路径也还 / 旧代不伪造释放 / 句柄幂等 ★ 控制项);红相=摘掉调用方的释放 ⇒ host 1314 (failed: 2)(另 2 条在两种状态下都该绿,如实记、不冒充 4/4);绿相 host 1314 (failed: 0);原始读数见 docs/verification-log.md 的「2026-09-29 preset 租约释放」一节。)(310 = 305 + 5:同日 客户端团队面板可读性重设计(owner 截图:「超长文字弹窗超长,而且文字太密集太多根本不知道应该看哪里」)新增 5 条客户端断言(高度上限 / 段标题字号严格大于正文 / 默认全折叠 / 点标题即展开且展开前后 panelLines 逐字相同 / 缺数段不折叠);红相 client 310 (failed: 5) —— 五条全红且套件跑完不崩,绿相 client 310 (failed: 0);本地视觉夹具读数 941px → 406px;原始读数见 docs/verification-log.md 的「2026-09-29 客户端团队面板可读性重设计」一节。)(1310 = 1306 + 4:2026-09-29 DSH 0.2.0-rc.1 preset pin 换代修复轮(standingKeyFor → acquireScope,真机 /team_session 两次失败的同日第二处换代断层)新增 4 条宿主断言(现名成立 / 两名都在只走现名 / 旧名回退成立 / 双缺显式点名断层);红相=夹具先行、实现未动时套件以真机同款文案崩(可见逐字相同的 创建失败(agentPresets.standingKeyFor is not a function),多条 FAIL 后中止于 U16 端到端,套件中止故无完整 failed 计数、如实登记);绿相 host 1310 (failed: 0) / client 305 (failed: 0);原始读数见 docs/verification-log.md 的「2026-09-29 DSH 0.2.0-rc.1 preset pin 换代」一节。)( / :2026-09-27 (客户端团队面板 + 形态可视化 + 🔵 登记 4 条 / 本批执行 3 条,① 已在上一批修完)新增 断言,另(host 3 条路由计数断言、client 1 条段落类序断言 —— 新增一条路由 / 一个分区逼出来的,断言照旧严格);红相读数分三段:C1 / 、C2 / 、C3 ;原始读数见 末节。)(:2026-09-27 真机冒烟修复轮 L1/L2 新增 10 条宿主断言;该轮聚合红相 = + ;另 3 条覆盖缺口类如实标 ★负相 —— 原始读数见 的「真机冒烟修复轮」一节。)(:2026-09-27 代码评审第 2 轮新增 条宿主断言,聚合红相 = + 3 条控制项 ★ 负相本就绿;原始读数见同档的「代码评审第 2 轮」一节。)(:2026-09-27 代码评审第 3 轮新增 条宿主断言,聚合红相 = (控制条件并入同条断言,无单独负相项);原始读数见同档的「代码评审第 3 轮」一节。)(:2026-09-27 (🔵#2/#3/#4/#5)新增 条宿主断言 —— 🔵#5 的 null 判据:红相 (行为半在已成立 ⇒ ★ 负相不冒充;真红的是与「行为修正」两半)、绿相 / ;原始读数见同档的「C 批分歧审计修复轮」一节。)( / :2026-09-27 (2 🟡 + 4 🔵)新增 条断言(host 6 / client 6)—— 🟡#1 面板类名↔样式规则 2 条 · 🟡#2 翻页不回卷 3 条 · 🔵#3 降级句随页 1 条 · 🔵#4 派生行团队归属 2 条 · 🔵#5 2 条 · 🔵#6 React 完备 2 条;红相 / (;另 2 条为控制/对照项 ★ 负相本就绿、1 条为实施后补强,均如实标注),绿相 / ;原始读数见同档的「C 批代码评审修复轮」一节。)
§10.2.8.10 之后的一条用法变化(值得先知道):/team_session 结算后会给调用方会话投一条短回执(成功 / 失败 / 取消三态都算,一行、≤120 码点)。收益是在空会话里发命令也能把那个会话从侧边栏「显出来」——此前它没有回合 ⇒ 隐身;无任务建出的 worker 也不再隐身(改投一具「最小唤醒」待命通知)。代价如实:每条命令多一个调用方模型回合(连取消也算一个),回执是真实消息,调用方上下文会永久增长一条 user + 一条 assistant。
node host-half.test.mjs # 宿主半边:工具面 / 策略 / 换届 / 恢复 / 红线锁
node client-half.test.mjs # 浏览器半边:卡片渲染 / 降级路径 / 槽位注册
两条都是自报计数的脚本(结尾打印 assertion total: N (failed: 0) 并以退出码表态);不要用 node --test(沙箱下 spawn EPERM)。
逐轮的 红相/绿相 证据账本已移到 docs/verification-log.md——0.3.1 → 0.4.0 每一轮的审计变异、修复前必红读数,以及本仓库的「变异验证」验收文化。本节的定位是面向读者:怎么跑、现在多少条、红线是什么。
红线由哪些断言把守(宿主套件内的源码级与运行时锁):模块级 inject 恒 4 项;投递 source 恰三成员;不引入任何新的会话日志事件类型;PolicyConfig 的键集;writerGate 函数体逐字节不变。
设计文档索引
| 文档 | 内容 |
|---|
docs/team-upgrade-design-2026-09-17.md | 实施级设计(v1.4):M1–M5 机制、伪代码与 schema、安全边界与红线、验收标准(U1–U11 + 集成演练)、§9 收尾修复设计、会诊 #27 与清单闭合台账 |
docs/collab-enhancements-design-2026-09-19.md | 协作增强设计:§10 ① 发送方可见性 A+D(已实施,U13–U15 见上)/ ② /team_session 自动建队(已实现,U16–U19 见上;2026-09-21 真机缺陷修订见其 §10.2.8 —— 输入文法 + 失败可见性 + 确认框边界三条,判据 U30–U34,已落码(2026-09-22,commit ee7c48a))/ §11 ③a 自动换届主路径(已实现,U20–U24 / U28 见上)/ §11.9 ③b 团队恢复工具 team_link_recover(已实现,0.3.8 随 ③ 一批落地;0.3.9 批次 2 又把 revive 的角色面放开到任意角色、给 reappoint 加了常驻的「自建继任者」候选)。发布与验证状态:①(发送方可见性)0.3.8 已发布并经真机验证(§12.1);② ③ 的宿主半边要等 DSH 重启窗口;H1/H3/H4 的结论见该档 §12。会诊 #37 纪要见 docs/consult-minutes/2026-09-19-consult-37-minutes.md,会诊 #43(§11.9 的裁定)见 docs/consult-minutes/2026-09-20-consult-43-minutes.md |
docs/hardening-and-recovery-design-2026-09-21.md | 加固与恢复设计(v1,0.3.9):① 导出路由接入平台信任栅栏 · ② 恢复能力加宽(revive 角色面 / reappoint 自建继任者)· ③ 侧栏「会话工具」入口(§4.3 是本入口 UI/交互的唯一事实源)· ④ 深链聚焦修复(§4.4);含 §5 红线 B1–B9、§6 判据 U1–U14、§9 假设与待验项、§11 分批。会诊 #62 纪要见 docs/consult-minutes/2026-09-21-consult-62-minutes.md |
docs/client-panel-batch-design-2026-09-27.md | C 批设计:客户端团队面板(六段只读 + 落点行)· 形态可视化(Lead/成员/多会话徽标)· 🔵 收尾(登记 4 条 / 本批执行 3 条,① 已在上一批修完);判据 W1–W9(§3.0 的跨半边取数通道落地为只读路由 GET /team-link/panel,与导出路由同一道栅栏 —— 新增判据 W9:面板读路径零写) |
|
Changelog
完整变更史见 CHANGELOG.md——0.3.1 → 0.4.2 逐版条目,每版按「修了什么 → 为什么 → 怎么验证」组织,行为修复都附变异验证证据(修复前必红 / 修复后全绿)。
最近一次发布:0.4.2(2026-09-29,发布时一次性收口 3 个「未发布」条目:DSH 0.2.0-rc.1 preset pin 换代 standingKeyFor → acquireScope · preset 租约释放 · 客户端团队面板可读性重设计)——(上一版 0.4.1(2026-09-29,发布时一次性收口 10 个「未发布」条目:DSH 0.2.0-rc.1 兼容性闸门 · 部署接线改 link: · 策略持久化自持化 · C 批客户端团队面板与形态可视化 · 六轮代码评审/分歧审计修复);再上一版 0.4.0(2026-09-27,发布时一次性收口「团队任务台账」+「可观测批:读窗扩展 · 团队状态卡 · 台账收件视图/派生回执」+「形态批:团队形态 · agent-team 档 · teammate 交卷提示」+三轮分歧/收尾修复轮) 见下)——加固与恢复(导出路由接入平台信任栅栏 · 恢复能力加宽 · 侧栏「会话工具」入口 · 深链聚焦修复)· §10.2.8 /team_session 三条真机缺陷修订(输入文法 · 结果可见性 · 确认框边界)· §10.2.8.9 / §10.2.8.10 四项裁定(默认团队名带日期 · 现任失联则释放并认领 · 调用方回执通道 4 · 无任务也投最小唤醒)。逐轮红相/绿相读数与真机取证见 docs/verification-log.md。
Credits
Fork 自 PwnKY/dsh-session-link——深链复制、/s/<id> 打开器、深链上下文注入均保留自上游,感谢上游工作。
License
MIT