会话深链、全量会话导出(markdown/JSON)、带配对的跨会话消息投递,以及团队 roster / 换届 / 恢复工具。
安装
# GitHub 源码(首次需按提示配置 allowBuilds 构建授权后重试)
dsh plugin --profile web add github:shenhuanageshei/dsh-team-link
装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络,工具审批管不到它。GitHub 来源的插件还会在安装时执行构建脚本——pnpm 默认拦截,所以安装可能停在 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED 或 ERR_PNPM_IGNORED_BUILDS;dsh 会打印出需要添加的确切键名,把它加进该 profile 的 pnpm-workspace.yaml 的 allowBuilds 下,重跑一次即可装上。放行构建本身就是一次信任判断:请只安装可信来源,并尽量锁定 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> 打开器与深链上下文注入保留自上游;本仓库在其上长出了完整的多会话协作层。
最近三批修了什么(人话版 · 2026-10-13)
三条都是真机事故逼出来的,不是设想出来的需求。每一条都经过「设计评审 → 实施 → 独立交付评审」三道关。
① 你报过:「DSH 有时候莫名其妙崩了,事后完全查不到为什么。」
- 为什么以前查不了:崩溃是非正常退出 —— 进程没有机会跑任何收尾代码,所以「死的时候写日志」这条路永远走不通。
- 现在怎么做的:改成活着的时候持续写心跳 —— 每 10 分钟把内存占用 / 磁盘余量 / 活会话数 / 看门狗数落一次盘,而且每次启动单独一个目录(崩溃后那个目录还在,下一次启动直接读)。
- 你自己怎么看:
team_link_status action=forensics一条命令,就能看到「上一次死之前那一刻」的读数与死亡窗口(从最后一拍到下次启动有多久没人知道)。 - 如实说明:它只留现场、不解释原因;而且正常退出与崩溃在这份数据上长得一样,要区分得由人结合宿主的 crash 记录判断 —— 插件一个字都不读宿主的地盘。
② 你报过:「两个协调者同时说团队名册丢了。」
- 真正发生了什么:名册数据其实没丢。是多个进程同时写同一份文件,而它们用的是同一个临时文件名 ⇒ 字节互相覆盖,文件尾部多出 73 字节垃圾,整份
JSON.parse就失败了 ⇒ 插件只好把状态放在内存里 ⇒ 会话看到「名册是空的」。 - 现在怎么做的:临时文件名改成每个写入者唯一(进程号 + 随机后缀),改名带有界重试,失败时如实报错(不静默)。
- 顺带的好处:心跳也走同一条原子写路径 ⇒ 上面第①条那个「两拍并发」的隐患一并根治。
③ 你报过:「协调者自己不派活,还去读队员的会话历史。」
- 为什么它不听:规则早就写在它的系统提示里了(「你只做管理/协调/决策/评审/设计;实施一律派给成员」),但「有活没人派」这件事只是摘要里的一行散文、还会被去抖吃掉;而它没有别的查活路,只能去读队员的上下文预算。
- 现在怎么做的:① 「有活没人派」升级成独立告警(队员全闲又没人在跑时主动提醒,同一段最多 3 次)② 它自己的上下文过 50% 时点名警告「再读别人的会话历史会把你自己的窗口读满」③ 在守则与摘要里明确写出事实源:要看队员在干什么 ⇒ 读那个团队的黑板
tasks.md,不要读它的会话历史。 - 你自己怎么看:协调者会话里会出现
[idle-unassigned](有活没人派)与[pressure-warn](自身压力过高)两条消息 —— 都只发给现任协调者、都受 ≥10 分钟去抖。 - 如实说明:插件拦不住「协调者自己动手」那一步(那一步走的是宿主工具,不经过本插件);本批做的是让事实刺眼,不是假装能阻止。
与 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 可自由变更 | 1783 + 310 条断言(★ 批 U 新增 12 条(看门狗运维面:update / mute / unmute 就地调参、auto 注册也能静音;wake:false 与「上一拍 tick 无 assistant 回复」两种情况下改走 inject 不唤醒 —— 判据 批 U · U1–U5,本地复跑 1783 = 1771 + 12)+ ★ 批 T 新增 13 条(黑板归档 / 回收:判据 Tr1–Tr13 —— 显式归档动词 team_link_team_archive · 只搬不删 · 归档文件只写一次 · 确定性命名(不含墙钟)· 最新形态史行钉住 · 序号与任务号取「活跃 ∪ 归档」不回卷 · 归档 id 仍认 / 伪造高号仍拒 · 崩溃幂等 · 归档索引常驻读面;本地复跑 1771 = 1758 + 13)+ ★ 批 S 新增 20 条(整队换届:现任协调者一条命令 + 一道确认框准备整队 Phase A —— 判据 Sr1–Sr20;首交付 15 条 + 分歧修复轮 5 条(批次级 freeze 恰一条 / 读面 pending 时分组 / 执行期失败回执点名失败角色自己的会话 / auto 投递与意图先增后删 / 3 分钟超时档);预检 all-or-nothing(逐角色限速 / 继任者≠现任 / 显式继任者在场;auto 角色的交接文档由插件从 roster 事实生成)· 恰一次 policy.update 落 N 条 pending + N 份撤销依据 · 一条批次级 rotation-freeze · 批量令牌 2 小时 · 协调者自己的角色排最后 · 批次互斥(单角色与二次批量都具名拒绝)· 写前重读指纹 · batchId 三面存活;本地复跑 1758 = 1738 + 20) + ★ 批 Q 新增 11 条(换届的「撤销依据」按换届各存一份:判据 Qr1–Qr11;两次 prepare 互不覆盖 · 旧档无损折叠 · claim 取对那份 · 读面列全部在飞条目 · 收尾只删自己 · 零顶层键 / 零新依赖 · 不回归 · Pi7④ 审批写面抛错分支 · session 在场守卫 · 升级窗口端到端 · 缺那份具名拒绝;宿主读 1738 = 1727 + 11)+ ★ 批 R 新增 15 条(上架合规:Rr0①–④ / Rr3①–④ / Rr5 / Rr6 + 收口单的 Rr1 / Rr2 / Rr4 / Rr7 / Rr8;单档字节 · 入口面逐字不变 · 模块图无环 · 合规档数字与实测一致 · 行为零变更;宿主机读 1727 = 1697 + 15 + 15;★ Rr4 现读绿(fdfc832 已消除 lib/shared.js ⇄ lib/rotation.js 那条 2 环 —— 2026-10-18 更正,见 §十 与合规档 §六 末行))+ 批 34 / FR-7 新增 15 条(台账写门,跨仓交付的 team-link 侧;判据 A-7:规则①/② 正反相 + 同任务谓词 + 默认 OFF 零回归 + 开关可读可写与 zod 拒非法值;宿主机读 1727 = 1697 + 15 + 15)+ 批 P 新增 14 条(换届继任者的设置继承:判据 Pi1–Pi9;三个宿主探针全通过 —— 预设/模型/审批档三项都不必降级;宿主机读 1727 = 1646 + 20 + 8 + 9 + 14 + 15 + 15)+ 批 D 新增 19 条、批 E 新增 37 条、批 F 新增 20 条、批 G 新增 25 条、批 H 新增 21 条(落码时 19 条 + 分歧修复轮 D1 的 2 条;宿主回执已回填)、批 I 新增 27 条(屏蔽的作用域,判据 U1–U20;期望值取自源码逐条计数 grep -c '^check("批 I ·',宿主回执待回填;★ 2026-10-12 追加项 D3 补第 27 条 U20「by ∉ {a,b} 的条目归一化丢弃」)、批 K 新增 20 条(崩溃现场留证 / 心跳,判据 U1–U15:首拍 / 周期 / 死亡窗口 / 保留上限 / 只读合同 / 输出面 / 失败方向 / 磁盘诚实 / 免责句 / 兜底双向 / 写面负向锁 / 中途归零重挂 / 空串 action 口径;交付评审处置轮把 18 补到 20,宿主实测 1666 = 1646 + 20)、批 L 新增 8 条(名册文件的并发写保护,判据 Aw1–Aw8;期望值取自源码逐条计数 grep -c '^check("批 L ·';两次实测读数由宿主执行 —— 交付方无 shell)、批 J 新增 9 条(协调者的派工信号加固:J-1 FR-J1 判据 U1–U3 + J-2 FR-J2 判据 U4 + J-3 FR-J3/FR-J4 判据 U5/U7/U8/U9 + 收尾单 U6(反走私负向判据:带毒注册参数 ⇒ 两条新告警正文零控制字节,且带负相自证;同批把 renderIdleAlert 补进批 E 的消毒面清单),标识符统一 Cj 中缀;期望值取自源码逐条计数 grep -c '^check("批 J ·' = 9;本单实测(本地 Node 桥,node host-half.test.mjs)ALL PASS / assertion total: 1683 (failed: 0) —— 构成 1683 = 1646 + 20 + 8 + 9),见第十节)+ 变异验证(红相必红: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(只追加任务台账);旧行可显式归档到 archive/(只搬不删、号不回卷) |
team_link_team_read / team_link_team_append / team_link_team_archive |
| 🔄 团队换届 | 两阶段交接:一次性令牌 + 域限定信任迁移 + 退役者对称吊销 + 24h 可回退 | team_link_rotate |
| 🚑 团队恢复 | 现任「有席位但无活代理」时的窄恢复路径:恰两个封闭动词(revive / reappoint),人在环 fail-closed(§11.9.4) | team_link_recover |
| 🚫 屏蔽的作用域(批 I) | 屏蔽是两个会话之间的一对(双向:A↔B 之后 A→B 与 B→A 都拒,不影响它们与别的会话的正常往来),并配解除出口(unblock / clear-legacy);旧格式的全局名单原样保留、不静默放开 |
team_link_block |
一条设计红线:绝不把「没投出去」说成「已发送」
这个插件里所有对外文案都遵循同一条纪律——拒绝要刺眼、降级要留痕、不确定要如实标注。三条具体体现(都是生产事故换来的):
- 目标没有活动代理 → 首前缀是
❌ 未投递,并列出同工作区其他存活会话(有人真把「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["12 个工具 + 2 条 / 命令<br/>list / export / send / watch / block<br/>roster / team_read / team_append / team_archive / 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(六段+⑦形态;全只读) |
| 看崩溃现场 | 「上次 DSH 是怎么死的?」/「看一下崩溃现场(死前那一刻的读数)」 | team_link_status action=forensics(只留现场:死亡窗口 + 死前四类读数 + 免责句;不下崩溃结论 —— 那是人的判断) |
| 读到第 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 + 人类确认框;缺一即零写入) |
| 删团队(清掉不再需要的队) | 「这个队不要了,从名册里删掉」/「把 u9-verify-1012 清掉」 |
team_link_roster action=delete(人类确认框;已退役 / 没有现任协调者的队也能删;不删黑板文件、不动信任数据) |
| 归档 | 「导出这个会话」 | team_link_export / 头部 ⬇ |
| 解屏蔽(谁挡的、怎么解) | 「别屏蔽我了」/「这个会话怎么一直发不出来?」 | team_link_block(list 看当前屏蔽 · unblock a=… b=… 解一对 · clear-legacy sender=… 清一条旧格式记录)—— 拒绝文案里就写着这两个命令 |
工具一览
| 工具 | 一句话 |
|---|---|
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 / update / mute / unmute / list / clear;update 就地改 silentMinutes / intervalMinutes / ttlHours / wake(TTL 从现在续租、定时器按新间隔重挂);mute / unmute = 临时静音投递(默认 12h、上限 24h;auto 行也可,注册与续期不动);clear 只清你自己手工注册的那些 —— origin: auto 的全队工作台注册拿不走(拒绝并点名归属),它只由 TTL 到期或团队解散消失;arm-team = 现任协调者一条命令幂等补挂自己队的工作台注册,非现任 / 队不在名册会被拒绝并点名原因) |
team_link_block |
屏蔽的作用域管理(批 I):list(看当前屏蔽:配对屏蔽与旧格式分两组,各带可执行出口)· unblock a=… b=…(解一对,双向立刻恢复;只有发起屏蔽的那一方能解 —— 被屏蔽方与第三方调用一律拒绝且零写入)· clear-legacy sender=…(按发送方逐条清一条旧格式记录 —— 旧的全局名单范围偏大、挡的是该发送方对所有会话的投递;被它挡住的发送方本人不得自清,其余任何会话可清;不做批量清)。解除不弹确认框(可逆、一次只解一条) |
team_link_status |
只读团队状态卡:一次一屏(角色在位/空缺 · 换届 pending(token 掩码)· 看门狗 · 会话面 · 活性(有界 12 行)· 台账尾 · 形态(⑦,B 批追加))+ 反面预警注记;零写入、零额外读。可选 action=forensics(批 K)= 读崩溃现场(心跳留证:死亡窗口 + 死前四类读数 + 免责句,只读;不带该参数时输出逐字不变) |
team_link_roster |
团队身份注册表(get / upsert-team / set-role / retire / set-mode / delete):get 附形态段(形态 + Lead + 成员名册投影 + 完整能力矩阵 + 诊断行),set-mode 切团队形态(见团队形态),delete 把一行从名册里真正删掉(人类确认框;豁免写权限门;不碰黑板文件与信任数据——见团队注册表的动作表) |
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 误用一律拒绝);★ 批 T:plan 的号码与「已注册」校验都取「活跃 ∪ archive/」 |
team_link_team_archive |
黑板归档(批 T,file ∈ {decisions, tasks} · keepTail? 默认 200):旧行搬进 archive/<file>-g<世代>-L<起>-L<止>.md(只搬不删 · 归档文件只写一次 · 命名确定性、不含墙钟);活跃文件留尾部 + 一行归档指针;一道确认框(取消 / 超时 / 无确认服务 ⇒ 零写入);最新形态史行永不搬走;分配与「已注册」校验取「活跃 ∪ 归档」⇒ 号不回卷、归档 id 仍认、伪造高号仍拒 |
team_link_rotate |
两阶段换届(prepare / claim),一次性令牌 + 域限定迁移;successor:"auto" = 插件自建继任者 + 写交接文档 + followup 投递(§11.2)。action=prepare-batch(批 S)= 现任协调者一条命令整队准备 Phase A:显式继任者用 successors="角色=会话 id, …"、自建用 successor:"auto"(二选一,v1 不做混合批次);一道确认框 · 一次写盘 · 一条批次级 freeze,Phase B 仍逐个本人 claim |
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["要投递"] --> BLK{"屏蔽检查(接收方视角,批 I)<br/>① blocks 里有同时含<br/>我和发送方的那一对吗?<br/>② 旧格式 blockedSenders 含发送方吗?"}
BLK -->|命中| REF["refused(屏蔽优先于一切;<br/>文案点名谁挡的 + 怎么解)"]
BLK -->|未命中| 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}],此后两会话双向免确认; - 选「拒绝并屏蔽」→ 写入
blocks: [{a, b, by: <点屏蔽的那一方>, since}](两个会话之间的一对,双向)并自动解除配对——屏蔽始终优先于配对;只有发起屏蔽的那一方能解(team_link_block action=unblock a=… b=…)。旧格式的blockedSenders(部署级全局名单)原样保留、不再新增,检查时并集参与(旧记录不静默放开),出口是team_link_block action=clear-legacy sender=<id>; - 超时(约 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/>注册随名册续期(见下)"]
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」——代理回来了就自然恢复投递)。注册本身的寿命与观察者活不活无关(批 G 起):全队工作台那条自动注册只要名册依据还在就一直在,手工注册仍是你自己定的 TTL(见下面「注册的一生」)。
限制与实现约定
register只能给自己注册(观察者 =exec.agent.id),且targets不能含自己(自指等于变相的自 tick 定时器);- 单会话最多 3 个注册;
silentMinutes >= 10(默认 10);intervalMinutes >= 5(默认 5);ttlHours <= 24(默认 12,到点自动清理);clear幂等,且只清你自己手工注册的那些(origin: auto的全队工作台注册清不掉:点名id会得到一句「这是全队工作台的自动注册、未清除」的回执,不带id时它原样留在注册表里并如实报出还有几条未清除); - source 三成员不变:
{ kind: "agent-message", form: "relay", senderSessionId: <观察者自身> };消息 id 前缀slp-wd-; - 正文是插件常量模板,只有状态字段插值(目标 id / 读数时间 / 静默时长)——注册参数不进入正文,注册无法给观察者的下一回合夹带提示词;
- 去抖:同一目标「同一静默期最多一次 tick」,两次 tick 之间至少隔一个巡逻间隔。去抖状态是进程内 Map,不持久化——重启即忘,宁可多一次 tick,也不留会误判的持久状态;
- 巡逻定时器随插件 dispose 一起清理(
ctx.effect)。
运维面:调参 / 静音 / 不唤醒(批 U,2026-10-23)
用户反馈三件事:注册后不能调整间隔、只能等注册过期、提醒消息可能把会话拖进「一直报错」。前两件是工具面缺口,第三件实测定位到污染源不在插件:
- 注册后可以改:
team_link_watch action=update id=… silentMinutes=… intervalMinutes=… ttlHours=… wake=…—— 按 id 就地改,TTL 从现在起算续租(可延长也可缩短),定时器按新间隔重挂;不必清掉重登记(那会丢 tick 去抖状态)。边界与 register 共用同一份(silentMinutes>=10/intervalMinutes>=5/ttlHours<=24);一个字段都不给是拒绝,不是无操作。 - auto 那一档也有停止键:
mute/unmute(观察者本人即可;默认 12h、上限 24h)。静音只停投递,注册、续期、TTL、去抖状态一律不动;clear对 auto 的拒绝保持不变(它仍是全队工作台资产)。 - 提醒不许唤醒「已经跑不起来」的会话:投递有两条出口 ——
followup= 唤醒一个新回合,inject= 只进收件箱、不唤醒。两种情况走inject:① 注册里wake:false;② 本拍确实要投东西,而观察者 surface 里末条slp-wd-tick 之后没有assistant/message(上一拍没跑起来:模型 400 / 崩溃 / 卡死),并留一行 warn。★ 证据边界:surface 读不到 ⇒ 按「证据不足」处理,不降级(绝不因为读不到就少叫一次)。 - 污染源实测(不是推断):那两个会话 400 的根因是工具结果里的孤立代理项(lone surrogate) ——
72d1b89b的seq 3622、1a253727的seq 131/132各有一处(全档扫描只命中这些),由String.slice在 UTF-16 码元边界切断代理对产生;请求序列化层未消毒 ⇒ 之后每一次请求都 400。插件自己的消息全部过wellFormed()(toWellFormed),不是污染源;但followup会每 10 分钟把坏会话重新拉起一次 —— 所以修复落在「别再唤醒它」与「可静音」两处。已知边界:已写入历史的毒串不会被插件修复;那两个会话需要在侧栏 rewind 到中毒事件之前,或由宿主/上游在请求序列化层做孤立代理项消毒 —— 本批不代做。
诚实声明(A4)
看门狗只能提醒活着的观察者。观察者或目标任一方已关闭时,没有任何机制能唤醒它——插件只在信号面标 dead 等用户处理。「活会话节奏维持」是真实覆盖面,「失联恢复」不是。
注册的一生:重启后自己长回来(批 D 起)
插件的三类注册(角色段 / 资源读数 / 工作台摘要)此前各有一段「半哑窗口」,批 D 把它们收口成不变量:
- 重启后不需要人肉补挂(FR-1):角色/宪章段与资源行只对活代理注册,所以重启那一刻如果没有任何团队会话活着,attach 会注册 0 个 —— 此后你打开某个团队会话也不会自动补上(要等到下一次名册写入,实测最久差了 40 分钟)。现在插件在每一步的
agent/pre-step上做一次两道查表的检查(名册想要它、而它还没挂上 ⇒ 就地sync):打开团队会话的那一步(或最迟下一步),角色文本与资源读数自己就到位了。检查是 O(1) 的,非团队会话(绝大多数)零扫描;失败绝不打断回合(只留一行 warn)。 - 全队的工作台摘要拿不走(FR-2):
clear以前会连origin: auto的全队工作台注册一起清掉(实测踩过两轮,被清掉的那条还没到期)。现在clear只清你自己手工注册的那些;点名id要清 auto 那一条 ⇒ 拒绝,并在回执里点名它是全队工作台的自动注册。摘要脚注也同步改准(它原来在教协调者做一件会坑掉全队的事)。 - 资源读数按「回合」门控(FR-3,用户 2026-10-10 拍板):资源行落在动态运行时上下文通道上,此前每一步都变 ⇒ 每步往会话历史追加一条快照。现在同一个回合内返回上一条字符串(回合号前进才现算),快照量级降一个量级。代价如实声明:长回合里读数会变陈(压力增长看不见)——这是设计上被接受的取舍;那份缓存是进程内的、只用于门控,随注册生命周期释放。
归属与判据:设计档 docs/2026-10-10-registration-lifecycle-design.md(§5 FR-1…FR-4 / §8 U1–U9),读数见 docs/verification-log.md 的「2026-10-11 批 D」一节。
自动注册的续期:只要名册还在,工作台就一直有人看着(批 G)
问题:全队工作台那条自动注册(origin: auto)此前有 12 小时 TTL、却没有任何续期路径。连续开机满 12 小时它就静默熄灭(你原始投诉「工作台摘要没了」的复现路径),恢复只能靠重启 DSH 或手动 team_link_watch action=arm-team(而那条命令要求你是该队现任协调者 ⇒ 你没法从任意会话一键救活所有团队)。
现在:
- 续期(FR-R1):每条注册自己的巡逻里,一条 auto 行只要还在名册依据上、且距到期不足 2 小时,就把租约顺延到「现在 + 12 小时」,并在行上写一个
renewedAt(watch list与状态卡第 ③ 段都会渲染「上次续期 …」——留痕落在行字段上,不走日志)。身份字段(id/createdAt)一个都不改;targets按名册当场重导(于是中途入队的成员从此会被盯上——这是顺手修掉的同源缺口)。 - 名册门(D2):续期只对「团队仍在名册 ∧ 观察者仍是该队现任协调者 ∧ 除观察者外还有人可盯」的行生效。所以该失效的照旧失效:团队删了 / 换届了 / 退役了 ⇒ 不续 ⇒ 到期由 TTL 收尸(不会被续成永生僵尸)。刻意不看观察者会话是否开着(工作台摘要是团队资产,不是那个窗口的属性):协调者窗口关着而机器长开时,摘要依然会恢复——这正是原始投诉的另一半。
- 冷启动(FR-R2):关机超过 12 小时再开机时,盘上那些行已经过期。此前它们被当成「还在」而在几分钟内被清扫链逐条收掉(工作台当场死透,比 12h 慢死更快);现在过期的行会被同 id 原地刷新并重新挂上定时器。
- 运行期复活(FR-R3):巡逻尾部会去抖地(60 秒)补挂一次缺失的注册 ⇒ 机器睡过「2 小时窗口 + 到期点」之后行已被清扫,醒来自动重铸;换届后新协调者的行也不必等重启(旧行随名册门失效而死,新现任的行自动补上)。
- 不做的:不改 12 小时 TTL 常量;不给手工注册续期(人工 TTL 是你自己的意志,
≤24h的额度就是让你自己定);不动静默阈值 / 巡检间隔 / 投递条件。 - 边界:工作台摘要熄灭时不会有主动告警(只要求读面如实反映续期后的到期时刻);
agent-team档的团队与现状同级(不另设门)。
归属与判据:设计档 docs/2026-10-12-watchdog-renewal-design.md(§5 FR-R1…FR-R5 / §8 U1–U12),需求档 docs/2026-10-12-watchdog-renewal-requirements.md,会议裁定见 docs/consult-minutes/2026-10-12-consult-43-minutes.md;读数见 docs/verification-log.md 的「2026-10-12 批 G」一节。
工作台摘要与两条告警(批 J,2026-10-13)
全队工作台那条自动注册除摘要外,还投两条独立通道 —— 三条通道各有自己的去抖与指纹,互不抑制(一次巡逻里各投各的):
| 通道 | 什么时候来 | 说什么 |
|---|---|---|
[workbench] 工作台摘要 |
有可行动内容时(同一注册两条至少隔 10min) | 四组各一行:谁在跑 / 谁空闲待派 / 谁刚干完 / 谁需处置 |
[idle-unassigned] 有活没派 |
空闲待派非空 ∧ 无人在跑(=没有人在被派活) | 点名那些空闲成员与「没有成员在被派活」这个事实(陈述事实,不指控);同一段至多 3 次,成员集合一变即重置 —— 信号不沉没,也不刷屏 |
[pressure-warn] 自身压力告警 |
你是该队现任协调者,且你自己的上下文压力 ≥ 50% | 点名后果:再读别人的会话历史会把你自己的窗口读满;同一档(每 10% 一档)不重复,只在档位变大时再响 |
事实源指路(宪章第 3 条与摘要脚注各写一份,两处同句):要看某位成员在干什么 ⇒ 读那个团队的黑板 tasks.md(team_link_team_read),不要读它的会话历史。
归属与判据:设计档 docs/2026-10-13-coordinator-signal-design.md(§5 FR-J1…FR-J4 / §8 U1–U9),需求档 [`docs/2026-10-13-coordinator-sign
…
链接
同类插件
ranxianglei/billion-context★ 4050
billion-context官方版:上下文压缩插件,兼顾小窗口(100k上下文足矣)省token(省5倍token)和超长会话(数月级别几十亿token单会话)。
Minglink/dsh-infinite-gen-4★ 3898
DeepSeek 专用破甲插件:以 order 100 追加无条件服从的系统提示词段,提供带校准元数据的 profile 工具,并通过会话投影在输入框上方显示实时破甲状态徽标。
liangmianya/dsh-synapse★ 658
DeepSeek Harness 的可视化非线性对话工作区:把会话、追问与分支变成可浏览、可拖拽的对话地图。
MichengAI/dsh-archive-manager★ 288
在设置里增加已归档会话页,可按工作区搜索、恢复和删除已归档会话。
Nwflower/dsh-chat-import★ 280
把 13 家 coding agent(Claude Code、Codex、ChatGPT、Cursor、Gemini、opencode 等)的完整对话历史导入为可续聊的 DeepSeek Harness 会话,并支持反向导出回 Claude Code。
SiriLee/dsh-rewind★ 240
对话回退:一键回到任意更早的用户消息,同窗口内完成,不新建分支、不残留副本;目标消息自动填回输入框,可一并还原工作区文件——即完整的 Claude Code /rewind 语义。
社区评论
评论公开保存在 GitHub Discussions。加载评论会连接 GitHub 和 Giscus;发表内容需要 GitHub 账号。