Skip to content
dsh.fish
Bundle

dsh-team-link

Session deep links + full session export + approved cross-session messaging + team identity/lifecycle (roster, watchdog, broadcast, rotation) for DeepSeek Harness (dsh). Formerly dsh-session-link-pro.

Source
shenhuanageshei
stars
4 stars
License
MIT
Updated
Updated 16 days ago

Readme

# 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-` 保持原样——它们是取证链,不做回写。

[![tests](https://img.shields.io/badge/tests-1314%20%2B%20310%20assertions-brightgreen)](#十测试)
[![version](https://img.shields.io/badge/version-0.4.2-blue)](CHANGELOG.md)
[![license](https://img.shields.io/badge/license-MIT-green)](#license)

Fork 自 [PwnKY/dsh-session-link](https://github.com/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 可自由变更 | 1314 + 310 条断言 + 变异验证(红相必红:形态批 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`](docs/comparison-agent-teams-2026-09-26.md)。

---

## 目录

- [与 DSH Agent Teams 的关系:正交,不是竞争](#与-dsh-agent-teams-的关系正交不是竞争)
- [实战效果:两个真实项目的长跑记录](#实战效果两个真实项目的长跑记录)
- [这是什么:三个痛点](#这是什么三个痛点)
- [架构](#架构)
- [快速开始](#快速开始)
- [工具一览](#工具一览)
- [一、跨会话消息](#一跨会话消息)
- [二、会话可视:列表 / 深链 / 导出](#二会话可视列表--深链--导出)
- [三、跨会话看门狗](#三跨会话看门狗)
- [团队状态卡(只读)](#团队状态卡只读)
- [团队形态(多会话档 ↔ agent-team 档)](#团队形态多会话档--agent-team-档)
- [四、团队:roster 与黑板](#四团队roster-与黑板)
- [五、团队换届 rotation](#五团队换届-rotation)
- [六、策略配置](#六策略配置)
- [七、服务获取与留痕(0.3.7 的关键修复)](#七服务获取与留痕037-的关键修复)
- [八、兼容性与字符串安全](#八兼容性与字符串安全)
- [九、安装](#九安装)
- [十、测试](#十测试)
- [设计文档索引](#设计文档索引)
- [Changelog](#changelog)
- [Credits](#credits) · [License](#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 假差异 · 台账断档(三次独立拦截) | 三条口径纪律入库 |

**为什么这很重要**——要点在两件常被误解的事上:

1. **不是同一个模型自查,而是不同模型、不同供应商的会话互相抓错。** 这些席位上坐过**不同家族**的模型,而且**每个会话可以单独指定 provider / 模型 / 思考强度,也能中途换模型**——身份挂在**会话**上,不挂在模型上。所以「互相核对」是真的互相(不同训练来源、不同盲区),而不是同一个脑子换个座位。
2. **做成这些事的主力是 flash 级执行模型,不是顶级模型。** 案例里多条工作线全程用 flash 级模型跑(便宜、快,才敢同时开多条),强模型只花在两处:**首轮设计评审**与**关键决策会诊**。质量由**组织 + 纪律**兜底,而不是「每个环节都用强模型」。

上面六例**没有一件是靠「更聪明的模型」挡住的**,全靠结构:独立上下文 ⇒ 天然各自核验;裁决与纪律落盘 ⇒ 记忆不靠上下文;跨线质询 ⇒ 没有「自己批自己」。这正是本插件那条设计红线(**报告必须原话点对点推;共享状态只能是「主张」,不能是「事实」**)的实测回报:**把报告换成「写张便条贴墙上、让主管自己看」,这六例中的每一例都会静默通过。**

### 这两个案例比「一个会话里的 agent team」多出了什么

| 维度 | 多会话编队(本插件 + 工程模式) | 会话内 agent team |
| --- | --- | --- |
| 核验 | 每线**各自核验**,且**跨线互相质询**——A 的结论被 B 的实测打回 | 自报结果,父会话只看到摘要 |
| 上下文 | 按角色切分:worker 只拿自己那一段,**便宜且少污染** | 继承父上下文,容易互相污染 |
| 续航 | 黑板 + 看门狗 + 换届 + 恢复:角色可在会话之间移交,**几十小时不断线** | 随父回合成败起落 |
| 审计 | 决策与纪律**落盘可导出**,事后能复查整条协作过程 | 只留在对话流里 |
| 覆盖 | 跨**任意**会话:别的团队、别的项目、人开的会话 | 限于本队内部 |
| 人的位置 | 人可直接进任一会话旁听 / 介入;工具层可读全队活性 | 人是调度者,队友是进程内编制 |

> **避免过度声称**:「队友要持久」**不是**差别——agent team 本身就是持久队友。差别在**载体**:会话是盘上、可被人类直接进入、可被移交的实体;teammate 是进程内的编制。所以「跨任意会话」与「换届 / 恢复」是它**结构上**够不到的,其余几项是程度的差别。与内置 Agent Teams 的逐项**事实**对照见 [上一节](#与-dsh-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`](https://github.com/shenhuanageshei/dsh-thincoder-suite)(工程模式)提供。两个案例里的「全链」都是这两半叠出来的:

| 环节 | 提供方 |
| --- | --- |
| 设计评审(`advisor` type=design)→ 签发**一次性设计令牌** | 工程模式 |
| 实施只能由 `eng_coder` 带令牌执行(无令牌物理拒工) | 工程模式 |
| 分歧审计(只读子代理)→ 代码评审(`advisor` type=code) | 工程模式 |
| 真跑门 / dry-run / 变异验证 / 推送后 readback | 两者共同——**纪律由本插件落进黑板**,工具由工程模式提供 |
| 跨会话派工 · 汇报 · 裁决回执 · 换届 · 记忆 · 失联巡检 | **本插件** |

一个可复述的判据:案例里每个批次都要走 **「设计 → 评审 → 令牌 → 实施 → 审计 → 代码评审 → 合并 → 部署」八道门**——前六道是工程模式的门,**跨会话的派工、回执、裁决与换届是本插件的门**。

### 如实标注:实测到的边界

| 边界 | 实测 | 已固化的应对 |
| --- | --- | --- |
| DSH 重启会清掉本插件的团队注册表(settings 索引) | 多次实测;**黑板磁盘文件本身完好,丢的是索引** | 纪律「重启后先验 roster 再写」;重建走「创建即认领」 |
| 工作线会话被重启带走后无法投递 | 多次实测(插件只标 `dead`,不会凭空唤醒) | 需人**在侧边栏重开一次**该标签页;看门狗把 `dead` 报给协调者 |
| 个别部署上消费方工具读不到正文 | 实战中反复出现 | 走**落盘文件 + 会话状态文件**两条替代读取路径 |
| 会诊(多模型并行)会跑题、也会越界写文件 | 实测:一轮 3/3 跑题;一次只读契约被违反(子进程改了仓库文件) | 严约束简报 + 只读面外部约束;违规写入按「**对不对 ≠ 有没有权做**」处置 |
| 跨会话投递面只覆盖根代理 | 设计如此(子代理不是可投递目标) | 需要在拒绝时说清原因并**指路**——见 [对比页 §6](docs/comparison-agent-teams-2026-09-26.md) 的候选改进 |

---

## 这是什么:三个痛点

在一个 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**,绝不静默降级(见[第七节](#七服务获取与留痕037-的关键修复)——0.3.7 修的就是一次「静默降级了整整一天没人发现」)。

---

## 架构

插件是标准的 DSH **bundle 插件**,分宿主半边与浏览器半边:

```mermaid
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/&lt;id&gt;"]
            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[("&lt;DSH_HOME 或 ~/.dsh&gt;/team-link/<br/>policy.json")]
    T -->|best-effort 镜像| BB[("&lt;workspace&gt;/team/&lt;name&gt;/<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 会话,同一个工作区目录。

```mermaid
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: 收到卡片并响应
```

**四步上手**:

1. **开两个会话**(同一个工作区目录)。协调者用强模型,worker 用 `flash` 即可;worker 的开场白一句话:「你是 worker,等主管派活」。
2. **建团队**(在协调者里说):「创建团队 `night`,我当协调者,把 B 注册为 worker」。创建即认领,**不需要手改任何配置**。
3. **建信任通道**(可选,但推荐):让 A 给 B 发一条消息 → 接收侧确认框里选「**配对:双向免确认**」→ 此后 A↔B 互发免确认。
4. **派活与记账**:「广播全队:……」/「这条记入裁决账本」/「更新纪律条款」。

**常用话术速查**:

| 想做什么 | 对模型说 | 背后工具 |
| --- | --- | --- |
| 看团队与活性 | 「现在团队状态如何 / 列一下其他会话」 | `team_link_list_sessions` |
| **看团队一屏现状** | 「出一张**团队状态卡**」(可加「只看 night」) | `team_link_status`(六段+⑦形态;全只读) |
| **读到第 13 行 / 点名读某人** | 「列会话,**从第 13 行起**读」/「**点名读** <会话 id> 的活性」 | `team_link_list_sessions` 的 `offset` / `readIds`(单次 ≤12 次面读) |
| 广播 | 「广播全队:<内容>」(仅现任协调者) | `send` + `targets=["team:<n>/*"]` |
| 点对点 | 「发给 worker1:<内容>」 | `send` + `targets=["team:<n>/worker1"]` |
| 按优先级/性质发 | 「以 P0 裁决回复 W1,引用我上条消息」 | `send` 的 `meta` 信封 |
| 记账 | 「这条记入裁决账本」 | `team_link_team_append`(`decisions`) |
| 改纪律 | 「更新纪律条款」 | `team_link_team_append`(`discipline`,带 baseHash) |
| 派活落账 | 「派给 W1 复核 §3 的行号,记一笔」 | `team_link_team_append`(`tasks` + `kind=plan`,返回 `t-<n>`) |
| 回报状态 | 「记一笔:t-7 我接了 / 声称完成 / 卡住了 / 存疑」 | `team_link_team_append`(`tasks` + `kind=claim/done/block/dispute` + `task=t-7`) |
| **看某个任务回过什么** | 「**t-7 收到过谁的回报**?我发出去之后对方动了没有?」 | `team_link_team_read` 的**收件视图** + **派生回执**(默认只读自己的会话面) |
| 盯人 | 「盯住 W1/W2,静默 15 分钟叫我」 | `team_link_watch` |
| 交班 | 「我下岗,让 session-X 接任」 | `team_link_rotate`(两阶段) |
| **切形态**(多会话 ↔ agent-team) | 「把团队切到 **agent-team 档**」/「**切回多会话档**」 | `team_link_roster` `action=set-mode`(**双重门**:writer gate + 人类确认框;缺一即零写入) |
| 归档 | 「导出这个会话」 | `team_link_export` / 头部 ⬇ |

---

## 工具一览

| 工具 | 一句话 |
| --- | --- |
| `team_link_list_sessions` | 同工作区其他会话 + 活性信号行(verdict 五态 / goal / 静默时长 / 读数时效戳);默认读前 12 行的活性,**要读更多用 `readIds`(点名)或 `offset`(分页)**——单次调用 ≤ 12 次 surface 读,末尾给「未读 Y 行 + 怎么读」 |
| `team_link_export` | 任意会话全量导出 md + JSON |
| `team_link_send` | 跨会话投递(单目标或 `targets` 广播 ≤8);`meta` 信封;返回带 busy 预判;**发送方自己那一行渲染成卡片**(§10.1 A/D) |
| `team_link_watch` | 给自己注册跨会话看门狗(register / list / clear) |
| `team_link_status` | 只读**团队状态卡**:一次一屏(角色在位/空缺 · 换届 pending(token 掩码)· 看门狗 · 会话面 · 活性(有界 12 行)· 台账尾 · **形态(⑦,B 批追加)**)+ 反面预警注记;**零写入**、零额外读 |
| `team_link_roster` | 团队身份注册表(get / upsert-team / set-role / retire / **set-mode**):`get` 附**形态段**(形态 + Lead + 成员名册投影 + 完整能力矩阵 + 诊断行),`set-mode` 切**团队形态**(见[团队形态](#团队形态多会话档--agent-team-档)) |
| `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

```mermaid
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` 的注释原先概括成「全模块唯一构造器」,那句**只对「本插件驱动的中继」成立**,对全模块不成立(已在注释里改成带范围的表述)。改这个形状要**三处一起改**——任何地方冒出第四处字面构造才是缺陷。

### 两道批准门与配对

```mermaid
flowchart TD
    S["要投递"] --> BLOCK{"在 blockedSenders 里?"}
    BLOCK -->|是| REF["refused(屏蔽优先于一切)"]
    BLOCK -->|否| PAIR{"双方已配对?"}
    PAIR -->|是| D["直接投递"]
    PAIR -->|否| G1["门 1 · 发送方确认<br/>发送 / 记住该目标免确认 / 取消"]
    G1 --> G2["门 2 · 接收方策略(receiveMode=ask)<br/>接收 / 总是接收该发送方<br/>配对:双向免确认 / 拒绝并屏蔽"]
    G2 -->|配对| D
    G2 -->|拒绝并屏蔽| REF
    G1 -->|取消| C["取消(超时约 3 分钟同样按取消处理)"]
```

- 接收方选「**配对**」→ 写入 `pairs: [{a, b, createdAt}]`,此后两会话**双向免确认**;
- 选「**拒绝并屏蔽**」→ 写入 `blockedSenders` 并**自动解除配对**——屏蔽始终优先于配对;
- 超时(约 3 分钟)按取消处理;确认服务不可用时**逐目标 fail-closed**(宁可不发,不默认放行)。

### 广播 fan-out(`targets`)

`targets` 与 `targetSessionId` **互斥**:两者都给是参数错误,都不给也是参数错误(单目标语义不变)。

| `targets` 项 | 解析 | 谁能用 |
| --- | --- | --- |
| `session-xxx` | 直达该会话(最高优先级) | 任何会话 |
| `team:<name>/<role>` | 该角色的现任会话 | 任何会话;角色空缺/不存在 → **`no-holder` 结果**(不算投递也不算失败) |
| `team:<name>/*` | 全队:该团队全部**在任且存活**的角色(不含发起者) | **仅该团队现任协调者**,否则整次调用拒绝 |

`team:<name>/*` 之所以收得这么紧:协调者的价值部分在于**策展每个 worker 看到什么**,而 flash worker 最稀缺的资源是上下文——全连通群播会让 worker 的上下文互相污染。

- 单次最多 **8** 个目标,超出即拒绝;团队不在 roster 或表达式形状非法 → **整次调用拒绝**(不做「半发」);
- fan-out **不放宽任何门**:每个目标照走完整单目标路径(屏蔽检查 → 配对快路径 → 发送方确认 → 接收方策略 → steer/followup)。N 个未配对目标就是 N 次批准;
- 返回逐目标结果行:

  ```
  - session-b(via team:night/worker1) → delivered:已投递(目标已空闲,唤醒为新回合)
  - session-c(via team:night/worker2) → refused:接收方拒绝
  汇总:1 投递 / 1 拒绝 / 1 个重复目标已去重。
  ```

- **失败领先**:只要有一个目标不是 `delivered`,**第一行**就是 `❌ N 个目标未投递(M 个已投递)`(N 含 refused / no-agent / no-holder,用词是「未投递」而非「失败」,故与汇总里 no-holder 的独立桶不矛盾),其后才是 `广播 fan-out:…` 头行、逐目标行与汇总。**全部成功时文案形状一字不变**;
- 重复的会话 id(含不同表达式解析到同一会话)**去重后只投一次**,去重个数写在汇总里。

### 信封 banner(`meta`)

可选 `meta: { type?: 'ruling'|'receipt'|'report'|'ask', pri?: 'P0'|'P1'|'P2', ref?: string }` 渲染进 banner **首行**的紧凑字段,只出现调用方给的键:

```
📨 [跨会话消息 · 来自会话「X」(session-x) · 2026-09-17 23:42:05 · type=ruling pri=P0 ref=slp-a1b2]
```

- `ref` 超过 **16 字符**按**码点**截断(不会切半 emoji),并在返回文案里注明截断前后;
- 枚举外的 `type`/`pri`、未定义字段、非对象 `meta`、空 `ref`、含换行的 `ref` → **明确参数错误、整次调用拒绝**(不静默丢弃、不部分采用);
- fan-out 时所有目标共享同一 `meta`;
- **`source` 仍是恰好三成员**:信封只走正文 banner,不扩 source、不做 sidecar 索引。

### busy 预判

投递成功的返回文案附带目标忙碌状态(fan-out 逐目标独立):

- 目标**运行中** → 追加 `目标回合已运行 N 分钟(steer 注入当前回合);需新回合语义请等其空闲`。`N` 取自活性行的「回合始于」,读不到该时间戳时只给 steer 语义、不给分钟数;
- 目标**空闲** → 保持原文案(已唤醒为新回合)。
- **发送方卡片上是同一读数的徽标形态**(不是上面那句机制文案):`忙碌 · 已运行 N 分钟`,读不到回合起点时只说 `忙碌中`——见「发送方卡片」一节的「A 面逐目标行的措辞」。

### 消息卡片(浏览器侧)

接收方 UI 把跨会话消息渲染为醒目的 📡 卡片(📡 标题行 + 高亮左边条 + 发送会话 + 时间):通过 `conversation.chat.node` keyed slot 以 `priority: -100` **影子替换** chat 包默认的折叠灰字行;非本插件消息(其他插件的 context 注入)经 `slots.entries()` 委托回原渲染器,显示不受影响。

判定条件不是「kind/form 命中」而是**本插件自己的消息**:`agent-message + relay` 正是上游相邻代理消息(`send_message`)用的形状,只按 kind/form 判断会把它们也渲染成卡片。因此卡片还要求命中本插件自己的特征之一——消息 id(在 chat node 上是 `node.id`,context 的 `data` 里没有 id)以 `slp-` 开头,或正文以 `📨 [跨会话消息` 开头;历史日志里的旧 `kind: "team-link"` 继续识别。卡片时间优先取旧日志的 `sentAt`,其次取 context node 自带的事件时间 `data.time`,最后才从正文 banner 里解析。

### 发送方卡片(`team_link_send` 自己那一行)

上面那张卡覆盖的是**接收方**;发送方过去只看到工具树里一行灰字。现在发送方也有卡,两条腿都在客户端:

**每一块信息只准出现一次**(§10.1.5 硬性条文,差异审计 F1 后写死):两张卡的可见文本取**并集**后,任一语句**恰好出现一次**。判据由 `client-half.test.mjs` 的 F1 三条断言把守——把任意一块搬回另一面,它们当场变红。

| 腿 | 位置 | 槽位 | 承载(且只承载这些) |
| --- | --- | --- | --- |
| **A** | 工具调用**原地**(审计记录不动) | `tool.call.toolview`,key = **线上工具名 `team_link_send`**(逐字;typo 会静默回退通用工具行、不报错) | **极简标签**(`✦ 工具调用 · team_link_send · N 个目标`)+ **逐目标行**:每行「目标(`expr` 或短 id)+ **outcome 的一句人话短语**(从结构化字段渲染,见下「A 面逐目标行的措辞」)」,运行中的目标带 busy 徽标。**不渲染**标题/时间/正文/汇总,**也不渲染 `target.detail`**(那句是模型可见的报告句) |
| **D** | 会话流**顶层** | 本插件自己的 `uiConversation` definition(kind `team-link-send`)+ 同 kind 的 `conversation.chat.node`(`priority: -90`) | 标题 + 发送方/时间 + **正文** + **汇总计数**。**不渲染逐目标明细行**(目标身份归 A 的行)。与接收方的 `key: "context"` 卡片**kind 不同**,并存不冲突 |

**A 面逐目标行的措辞(2026-09-20 用户决定;设计 §10.1.5 修订 + §12.5)**:A 的每一行**不再原样搬运 `target.detail`**——那句是**模型可见的报告句**,带「已投递到 …」「目标处于空闲」「steer 注入当前回合」这类**投递机制**,对人看的卡片偏机制而非结论,且一个目标就占 2–3 行。改为**从结构化字段渲染一句人话**:

- `target.outcome` → 一句**固定短语**(客户端 `OUTCOME_PHRASES` 映射到 zh/en 两套 locale 键,**不是硬编码**):`delivered` → 「已送达」、`refused` → 「未送达——接收方拒绝」、`no-agent` → 「未送达——目标会话没有活动代理」、`no-holder` → 「未送达——该角色当前空缺」;
- `target.busy` → **徽标**:「忙碌 · 已运行 N 分钟」(读不到回合起点时只说「忙碌中」,不编数字);
- 目标身份 → `expr` 或**短 id**(不变)。

于是**一行 = 目标 + 一句结论 +(忙碌时)徽标**,长报告句(含机制、含 `busy` 的 steer 文案)继续留给**模型可见的文本**。**这不违反「一处事实」**:报告句与卡片短语是**同一批结构化字段的两种渲染**(`outcome` / `busy` / 身份),`target.detail` 仍**留在回执里**(模型可见文本的事实源 + 纯文本降级路径的兜底),只是不再是 A 的渲染输入。

| 实测对照(同一次投递,`team:night-shift/*` 广播;「改前」句取自 `client-half.test.mjs` 的旧期望值 fixture——那行 **80 字符**、卡片按 `pre-wrap` 折成 2–3 行,「改后」行 44 字符) | 文本 |
| --- | --- |
| **改前** A 面逐目标行 | `session-worker-a(via team:night-shift/*) 已投递 已投递到 session-worker-a:目标空闲,已唤醒目标会话。` |
| **改后** A 面逐目标行 | `session-worker-a(via team:night-shift/*) 已送达` |

**跨半边行为锁(取代 B1 的字符串等式)**:旧的「卡内行 == 报告首行」断言已**作废**(它钉的是一个已被设计替换的渲染,且同源化之后**由构造保证永不红**,§12.3 ⑤)。现在锁的是**行为**:宿主半边**能铸出的每个 `outcome` 枚举值都必须有客户端短语**——`host-half.test.mjs` 同时读 `lib/index.js` 与 `lib/client.js`,把宿主侧的字面量 token 集合与客户端 `OUTCOME_PHRASES` 的键**判等**;**新增一个枚举值而不给卡片短语 → 必红**(红相实测见「测试」一节的对应条目)。测试报告里会打印两侧的实测集合。

**数据来源是官方载体,不是解析返回文本**:宿主半边给 `team_link_send` 加了 `output.presentationMeta`,产出的结构化回执落在 `tool/result.meta` 里(durable——回放同一份日志会重建同一张卡):

```
{ kind: "team-link-send", v: 1, at, senderSessionId,
  meta?: { type?, pri?, ref? },                 // 仅调用方给了信封才有
  message: { text, truncated, chars },          // chars = 原始码点数
  targets: [{ expr?, sessionId | null, outcome, detail, busy? }],
  targetsTruncated?: { shown, total },          // 仅 targets 真被裁到 24 行才有
  summary: { delivered, refused, noAgent, noHolder, deduped }, fanout }
```

- **体积纪律(两处上限,各自如实标注)**:
  - **正文**:`message.text` 上限 **2000 码点**,超出则取头 **1500** + 省略标记 **3 码点** + 尾 **400**(1903 码点,仍在限内)并置 `truncated: true`;`chars` 记**原始**码点数。裁剪与计数都按**码点**;
  - **逐目标行**:**宿主侧(权威)** `targets` 封顶 **24 行**(`SEND_CARD_ROW_LIMIT`,= §10.2.4 的每队成员上限,全队广播仍可整份渲染)。界是**行数**不是**表达式数**:输入侧 `resolveTargetList` 裁的是表达式(≤8),而**一个** `team:<name>/*` 就展开成该队全部在册存活成员,所以合法的行数可以超过 8——**持久化的卡必须有界**,`tool/result.meta` 里被烙进日志的正是卡的 `targets`。超出时卡内**显式标注** `targetsTruncated: { shown: 24, total }`(`shown` = 裁完实际呈现的行数——客户端 `sendRowsTruncated`「已截断——仅显示前 {shown} 行」的口径同样是**实际画出的行数**,良构宿主卡上两者同值;`total` 是这次投递真实的行数),而 **`summary` 计数仍覆盖全量**、**文本报告仍逐目标完整**——卡是**有界呈现**,报告是**全量档案**;
  - **客户端(防御 + 如实呈现;2026-09-19 收尾轮补齐跨轮读路径)**:A 面渲染期另设同值上限,因为 `meta` 核心不透明且**持久化**,手改日志或异构实现可塞进任意条数。**这条判据对宿主自产的卡永远不触发**(宿主已先裁到 24,行数恰好落在界上),所以 A 面**同时读宿主自己的 `targetsTruncated` 标注**:只要卡上有该标注,**或**行数确实超过本地上限,A 面就渲染 `sendRowsTruncated`「已截断——仅显示前 {shown} 行」——两类超限都在界面上看得出来。两处数字各有各的口径:`{shown}` 是**实际画出的行数**(不是标注里写的数字——手改的 `shown` 不得在那个句子里安一个假数字),标签的「N 个目标」取**真值**——有标注时是 `targetsTruncated.total`(30),无标注时是该卡自己的行数(外来卡的 30 行就是真值;而只画出的那 24 行**不得**当真值)。标注本身与其它 `meta` 字段同等不信任:形状不认(`shown` / `total` 非有限数)即**丢弃**并退回「按行数判超限」的防御路径,卡照常渲染。**无标注且 ≤24 行**的卡可见文本与加这条读路径之前**逐字一致**(不增删任何文本);
- **良构(差异审计 F2 修正)**:**卡内的每一个字符串成员**——`message.text`、逐目标的 `sessionId` / `expr` / `detail`、`senderSessionId`、信封的 `ref`——都在制卡时过一遍孤立代理项修复。卡**不走** `textOutput.render`,模型可见出口盖不住它,所以这条线必须逐个字段自己守住;回归锁在同一声明的三条断言上(污染 `sessionId` / `expr` / `meta.ref` 后 `JSON.stringify(card)` 无孤立代理项);
- **降级**:拿不到回执时(调用仍在飞、旧日志没有 `meta`、`meta` 形状不认识、其他工具的 meta)先走「文本重建」,重建不出来才回退**纯文本行**(三种情形的判定表见下面「三种情形」);整次调用在**走到逐目标投递之前**就被拒(寻址互斥 / 无地址 / `meta` 非法 / 执行上下文没有可交互的活动代理)时**不产出卡**,客户端回退文本——绝不为没发生的投递编造回执。注意区分:**目标**无活动代理(`outcome: "no-agent"`)发生在投递阶段内,**照常出卡**,那一行就是那条 `❌ 未投递` 拒绝;
- **零日志改动**:A/D 都只**读**既有的 `tool/call` + `tool/result` 事件,**不新增任何日志事件类型**(§10.3 红线);投递消息的 `source` 仍恰三成员;模型上下文无新增消息。

#### 三种情形:卡片 / 文本重建 / 纯文本行(§10.1.5,DEFECT-5)

A 面(`tool.call.toolview`)**逐块判定,顺序即优先级**:

| # | 情形 | 判据 | A 面呈现 |
| --- | --- | --- | --- |
| ① | **有结构化回执** | `tool/result.meta` 读得动(`kind: "team-link-send"`、`v: 1`、字段齐) | **卡片**,数据全部来自回执。文本重建在这一情形里**永不参与**(下面 ① 号锁) |
| ② | **无回执(或回执读不动),但文本认得** | 模型可见文本是我们自己产的两种形状之一 | **文本重建的最小卡**:目标身份 + 结果短语 + 汇总计数从**文本**解出;`at` / 发送方 / 正文 / `busy` / `detail` **不造**(文本里没有这些字段) |
| ③ | **两者都不成** | 在飞、无文本、认不出的句子、跨行/改写/自相矛盾的报告 | **纯文本行**:原样显示模型可见文本,一行不吞、一处不改 |

**② 什么时候会发生(实测定性,不是推断)**:`presentationMeta` 只在**一等工具调用**上投影(`dsh-tools/lib/types/index.js:1191` 的 `exec.parent === undefined`),所以**在 `run_code` 程序里发起的** `team_link_send` **永远没有** `tool/result.meta`——桥为那次派发记的是 `tool/ptc-dispatch`,其字段恰为 `{rootCallId, parentCallId, subCallId, name, arguments, isError, content}`(**没有 `meta`**),客户端 chat 包据此建的块(`childResult`)也不带 `meta`。**只读全量扫描**本机 1311 个会话日志:`team_link_send` 的 `ptc-dispatch` **733 次、带 `meta` 的 0 次**;同一批里 `tool/result` 带本插件回执的有 **11** 条(都来自一等调用)。⇒ 此前**从代码里发出的那一次投递永远是灰行**,而直接调用的一直有卡。

**② 认的两种形状(我们自己的渲染器逐字产出,`lib/index.js`)**:

- **单目标成功**:`已投递到 <sessionLabel>(<通道说明>):<细节>`——`sessionLabel` 是 `id` 或 `「标题」(id)`,通道说明是配对 / provisional 两种之一(也可以没有)。解析时**按形状**剥掉这两层,不硬编码那两句提示语;
- **广播报告**:可选的 `❌ N 个目标未投递(M 个已投递)` 首行 + `广播 fan-out:N 个目标[(重复目标已去重 N 个)]` + 逐目标行 `- <目标> → <结果>:<细节>` + `汇总:N 投递 / N 拒绝[ / N 无活动代理][ / N 空缺目标(no-holder,不计入投递与失败)][ / N 个重复目标已去重]。` + 可选的 `注意:…` 尾注。逐目标行的 `<细节>` **可以跨行**(`no-agent` 的拒绝是一整段,自带缩进列表),那些续行属于**同一行**,不会变成额外的目标行。

**② 是「全有或全无」,并且自校验**:报告里的每个结构事实都要与其它事实**互相印证**才成卡——行数 == 表头声明的目标数、`汇总` 四个桶 == 逐行自己的计数、去重数(表头与汇总两处)一致、`❌` 首行**有且仅当**存在非 `delivered` 的行、且它复述的两个数字与逐行计数相同。任一处不符(含标签认不出、`outcome` 落在四种之外)⇒ **整条回落 ③**,而不是丢掉那一行或猜一个桶。理由就是「不伪造」:单目标**被拒**的句子(`未投递:…` / `发送失败:…`)**根本不含目标身份**,为它出卡就必须凭空造出「目标」这个字段——按设计那句话(`缺的字段不造`)它只能留在 ③。

**重建出来的卡只在 A 面**:D 的顶层节点由 `tool/result.meta` 的判别符经 definition `match` 驱动,所以**文本重建永远长不出顶层卡**。

**已知边界(如实声明)**:`presentationMeta` 只对**顶层**工具调用投影(`exec.parent === undefined`),所以从 `run_code` 程序里发出的 `team_link_send` 拿不到结构化回执——那一行走的是上面的**文本重建**(② 情形),重建不出来才是纯文本行;D 的顶层节点在 chat 包把「回合过程」折叠起来时可能随之被折进去(`tool-call` 节点本身也是这个待遇)——`tool/call` 滚出历史窗口、只剩 `tool/result` 时按 `context.matches` 回退重建,卡片不会在长会话里凭空消失。**客户端半边对 `uiConversation` 不是硬依赖**(审计 F3):模块级 `inject` 只有 `slots`/`sessions`/`locale`,definition 走 `ctx.inject(["uiConversation"], …)` 动态注册,因此缺该服务的老壳**只丢 D 的顶层卡**——接收方卡片、工具行、复制/导出按钮、深链打开器全部照常;**四条槽位注册(header 按钮条 + 三条 §10.1)各自加护栏**(审计 B3;第四条为 round-1 🔵 #3 补齐),任一条被槽位拒绝也只丢那一行。

### 诚实声明(A4)

跨会话投递只能送达**有活动代理**的会话:目标已关闭、或刚重启 DSH 后尚未在侧边栏打开过,都没有任何机制能唤醒它。插件能做的只有**如实告知 + 给出恢复动作**,而不是假装发送成功。

---

## 二、会话可视:列表 / 深链 / 导出

### `team_link_list_sessions` 的活性信号

每个会话行带一条活性信号(读一次 surface + 一次 agent 查询,**纯服务调用,不解析日志**)。

**读取是有窗口的**:只有列表前 **12** 个会话(`PREVIEW_SESSIONS`,与主题/最近摘要同一个窗口)会被读 surface,且这 12 次读取**并行**发出。第 13 行及以后照旧列出(id / 运行状态 / 创建时间 / 读数时效戳——全是零日志成本的面),但活性行降级为 `活性:未读(超出快照窗口 12)……`:verdict / 静默时长 / goal / 主题 / 最近动态一律标为未判定。

**要读更多 = 点名或分页(两个可选、互斥的参数)** —— `12` 是**预算**(一行 surface = 一次冷日志解压 + 一次投影),不是能力上限;所以**默认值不动**,把「读哪些」的决定权交回调用方:

| 参数 | 语义 | 边界(一律**拒绝**,不静默截断) |
| --- | --- | --- |
| `readIds?: string[]` | **点名读**:把指定会话强制纳入本次读窗(用于窗口外的行) | 去重后 ≤ **12** 个,超出即拒绝且**本次零读**;只接受**本次列表内**的会话 id,未知 / 不属于本列表的 id → 拒绝并**指出那个 id**;点名**优先占额**,其余按原顺序补足到 12;本就在默认窗内的 id 只占一个额、**不重复读**;与 `offset` 互斥 |
| `offset?: number` | **分页**:从第 `offset+1` 行起读 12 行(默认 0) | 必须是整数;负数 / 越界(≥ 列表行数)→ 拒绝并给出**有效区间** `0..行数-1`;与 `readIds` 互斥 |

**成本不变量(按工具分账,断言锁死)**:`team_link_list_sessions` 的三条路径(默认 / `offset` / `readIds`)**单次调用都 ≤ 12 次 surface 读**;`team_link_team_read` 的收件视图 + 派生回执 ≤ `min(12, 1+|readIds|)`(**自读占 1 个额**);`team_link_status` ≤ 12。

**列表末尾的「未读 Y 行 + 怎么读」提示段**(默认调用也带,是**提示不是载荷**):把「未读」从终点变成下一步 —— 形如 `未读 2 行(读窗 12/共 14 行):读第 13–24 行 → offset=12;或点名 → readIds=["session-x", "session-y"]`。**既有行结构一字未改**,本次唯一的格式新增就是这一段(外加新参数)。

> **为什么要有界**:一行 surface = 一次冷日志解压 + 一次表面投影。真实工作区实测(26 个会话)逐个串行读满 `LIST_LIMIT`(50)会直接超掉 60s 工具预算——0.3.5 修的就是这个(修前超时、修后约 15s)。这里选择**有界 + 并行 + 如实标注**,而不是编一个没读过的判定。

窗口内的字段:

| 字段 | 含义 |
| --- | --- |
| `verdict` | 五态判定(见下) |
| `代理` | `运行中` / `空闲` / `未运行`(读 agent 注册表) |
| `goal` | `<phase>/<activation>(<已用轮次>/<上限>)`;blocked 另带 ` blocked=<code>: <message>`;`none` = 当前无 goal;**`?` = goals 服务缺失**(降级运行,插件功能不受影响) |
| `静默` | `now - max(末条 assistant, 末条入站)`(分钟);两侧时间戳都读不到时显示 `?` |
| 回合始于 / 末条助手 / 末条入站 | 绝对时间戳(本地时区) |

**verdict 五态**(阈值:静默 10 分钟、回合 30 分钟;`team_link_watch` 的 `silentMinutes` 只影响巡逻判定):

```mermaid
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` 吞掉 ⇒ 静默空调用;见[侧栏「会话工具」入口](#侧栏会话工具入口039-起)一节的末段。

### `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 都由**宿主渲染好整串**,客户端只显示不重算(「同源」由构造保证) |

**已知限制**:

1. **收起侧栏 + Windows 标题栏模式**(`[data-windows-titlebar]`)下,官方 CSS 会隐藏整个 `footArea`(连同【设置】)⇒ 本入口一并不可见;展开侧栏即可用。
2. 状态点**刻意只有两态**:第三态「无活动代理」是**宿主侧**事实(宿主半边读 `ctx.agents.get(id)`),而客户端公开面里 `SessionSummary` 没有 liveness 字段、`SessionProjectionMap` 的三个键也没有 ⇒ 画第三态只能靠编造读数,因此不画(官方侧栏自己的状态点同样不含 liveness,口径一致)。**⚠ 前提更正(C 批,2026-09-27)**:本条原文写的「本插件浏览器半边**没有任何跨半边取数通道**」**已被取代** —— 通道一直是 `GET /team-link/export`(下载路由)那条;C 批把它扩成一对**只读**路由,新增 `GET /team-link/panel`(同一道栅栏)。**但「会话列表的状态点仍是两态」这条结论不变**,理由换成可核的那一条:会话列表渲染的是 `SessionSummary`(客户端快照),而活性读数走的是**面板那条路由**(宿主渲染、一次一屏),二者不同源,所以列表行的点**不冒充**活性(面板自己那一段照旧给真 verdict / 「未读」)。
3. 入口的可用性取决于三个客户端服务**都**在场(见 §九「浏览器半边的模块声明」);缺席时按上表「服务缺失」降级。

**深链聚焦修复(§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 策略

```mermaid
flowchart TD
    P["巡逻"] --> W1{"观察者自己<br/>运行中 / armed-active?"}
    W1 -->|是| SKIP1["不 tick<br/>绝不打断运行中的回合"]
    W1 -->|否| W2{"观察者代理还在?"}
    W2 -->|否| SKIP2["不 tick<br/>标「观察者=dead(等待用户)」<br/>注册保留至 TTL"]
    W2 -->|是| T{"目标 verdict / goal 状态"}
    T -->|目标 armed-active| S3["不 tick<br/>它有续跑节拍"]
    T -->|silent-idle| TK["tick<br/>同一静默期最多一次"]
    T -->|goal-disarmed| TK2["<b>立即</b> tick(不等静默阈)<br/>载荷带诊断 + 合规 resume 回路"]
    T -->|dead| TK3["tick<br/>但唤不醒它:标 dead 等用户"]
    T -->|paused / blocked / complete| S4["不 tick<br/>只在活性行展示"]
```

| 目标 goal 状态 | tick? | 理由 |
| --- | --- | --- |
| `armed` + `active` | 永不 | 它有自己的续跑节拍,tick 只稀释节奏 |
| `active` + `disarmed` | **立即**(不等静默阈) | 根因级静默态;载荷带诊断与合规恢复回路(转告用户 → 用户授权 → 模型自己 `update_goal(action:"resume")`) |
| `paused` / `blocked` / `complete` | 不 tick | 在等人类决策 / 已完结 |
| 无 goal | 静默超阈才 tick | P1 场景 |

观察者侧:自己**运行中**或自身 **armed-active** 时不 tick;自己代理不存在时不 tick、不改注册(该会话在活性行里本来就是 `代理=未运行`,`watch list` 另标「观察者=dead」,注册保留到 TTL 到期自清——代理回来了就自然恢复投递)。

### 限制与实现约定

- `register` **只能给自己注册**(观察者 = `exec.agent.id`),且 `targets` 不能含自己(自指等于变相的自 tick 定时器);
- 单会话最多 **3** 个注册;`silentMinutes >= 10`(默认 10);`intervalMinutes >= 5`(默认 5);`ttlHours <= 24`(默认 12,到点自动清理);`clear` 幂等且只能清自己的;
- **source 三成员不变**:`{ kind: "agent-message", form: "relay", senderSessionId: <观察者自身> }`;消息 id 前缀 `slp-wd-`;
- **正文是插件常量模板**,只有状态字段插值(目标 id / 读数时间 / 静默时长)——注册参数不进入正文,**注册无法给观察者的下一回合夹带提示词**;
- **去抖**:同一目标「同一静默期最多一次 tick」,两次 tick 之间至少隔一个巡逻间隔。去抖状态是**进程内 Map,不持久化**——重启即忘,宁可多一次 tick,也不留会误判的持久状态;
- 巡逻定时器随插件 dispose 一起清理(`ctx.effect`)。

### 诚实声明(A4)

看门狗只能提醒**活着**的观察者。观察者或目标任一方已关闭时,没有任何机制能唤醒它——插件只在信号面标 `dead` 等用户处理。「活会话节奏维持」是真实覆盖面,「失联恢复」不是。

---

## 团队状态卡(只读)

`team_link_status` 回答的是「**人**想知道团队还活着没有」——夜里三点团队卡住时,一次调用一屏讲清现状(设计档 §4.2)。**全只读**:不写设置、不写磁盘、不加缓存、不新增持久状态;调用前后 settings 与文件逐字节不变(连换届过期清扫都不跑——那会写)。

| 段 | 内容 | 成本 |
| --- | --- | --- |
| ① 团队与角色 | 每个团队一行(workspace / policy.writer / 角色数)+ 每角色一行:**在位**(现任 id 与任期起点)或**空缺(vacant)**,外加**版本史末条** | settings 读,零 |
| ② 换届 pending | 在飞令牌:团队/角色 → 继任者、**token 掩码**(`tok-1a2b…9f0e`)、创建 / 到期、绑定三元组、已迁移条数 | settings 读,零 |
| ③ 看门狗 | `policy.watchdogs` 的每一条:观察者(附代理状态)→ 目标、静默阈、巡检间隔、到期 | settings 读,零 |
| ④ 会话面 | 同工作区其他会话的 id / 代理状态 / 创建时间 / provisional 配对标记 | 注册表读,**不读日志** |
| ⑤ 活性 | 与 `team_link_list_sessions` **同一顺序读前 12 行**(`PREVIEW_SESSIONS`):verdict / 代理 / goal / 静默 / 时间戳;超出窗口的行**如实标未读** | 一次有界并行 surface 读(≤12) |
| ⑥ 台账尾 | 每个团队的 `tasks.md` 末尾 `tasksTail` 条(默认 **5**、上限 **20**,超出**拒绝**)与 baseHash | 每团队一次文件读 |
| ⑦ **形态**(B 批追加) | 每个团队一行形态 + Lead;成员名册**投影**(读时现算;不可读时**如实标注**并点名原因);**完整能力矩阵**(事实源 = 父档 §2.3)+ 那句结论;**形态诊断行**(除自己外没有其他可达会话时只提示、绝不自动切档) | 宿主投影读(一次 `ctx.get("agentTeams")` 调用,零日志)+本卡已读到的会话面 |

| 参数 | 语义 | 边界 |
| --- | --- | --- |
| `team?` | 只看这一个团队 | 省略 ⇒ **聚合设置中的全部团队**(每团队一段,不按工作区过滤——团队在本插件里按名全局寻址,与会话列表的工作区过滤不是一回事);给了但不存在 ⇒ **拒绝并列出已知团队名** |
| `tasksTail?` | 台账显示最后几条 | 默认 **5**;上限 **20**,超出或非整数 → 拒绝并给出有效区间 `1..20` |

**反面预警注记(§4.2,会诊提出)**:窗口内**一条消息都没有**、而台账同期**新增了行** ⇒ 说明大家可能已经**停止说话、只写行**(互核正在死亡的前导指标)。判据写死:锚 `T0` = 本读窗内**最旧**的一条 surface 消息时间戳;`N` = `T0` 之后、本读窗内的消息条数;`M` = `T0` 之后 `tasks.md` 的新增行数;**只有 `N == 0 且 M > 0` 才打注记**,否则不打。两个计数都取自**已经读到**的东西 ⇒ **零额外读**。**三种情形逐条写死**(设计 §4.2,三态各有断言):**① 读窗为空**(本工作区没有其他会话)⇒ **整段不打印**(连段标题都不出现);**② 读窗非空、但窗内没有一条带时间戳的 surface 消息**(锚取不到)⇒ 打一句 `(反面预警注记无法计算:本读窗内没有带时间戳的 surface 消息——不猜。)`,**诚实优于沉默**,台账里有多少行都不改这个结论;**③ 锚取到** ⇒ 按上面那条唯一判据判 触发 / 不触发。**聚合口径**:省略 `team` 时 `M` = **所示各团队** `tasks.md` 同期新增行数的**和**(单团队时就是那一家)——读面上它只是一个数,**不逐团队拆分**。**它是读数,不是裁决**:只陈述计数,不下「互核已死」的结论。

**诚实声明**:⑤ 段只读前 12 行会话(与列表工具同一条预算、同一种标注),第 13 行起的活性**未判定**;调用方没有会话身份时卡照出,「当前会话」处写 `(当前会话未知)`——**不编造身份**。

---

(**⑦ 形态**由形态批追加:卡尾一段给出每个团队的形态 + Lead、宿主成员名册**投影**、**完整能力矩阵**与「单会话兜底档」那句结论,必要时附**形态诊断行**——见下一节。)

---

## 团队形态(多会话档 ↔ agent-team 档)

形态**不是功能开关,是通道选择**(设计档 [§4.1](docs/team-mode-batch-design-2026-09-26.md),来源是[《台账 + 形态设计》§2.3](docs/team-ledger-and-mode-design-2026-09-26.md)):

- **多会话档**(`sessions`,默认):平级会话之间,靠本插件的**跨会话身份与信任层**(投递 / 配对 / 换届 / 黑板);
- **agent-team 档**:一个会话**内部**的子代理团队(宿主 Agent Teams)。**本插件在这一档下只做名册与指路**,**不代理**宿主的派活 / 任务板(那是宿主的属地)。

**三句要紧的话**:

1. **人说了算**:人在对话里说一声 → 模型发起 → **弹一次确认框** → 人点一下才落笔(并往 `decisions.md` 记一行形态史)。**没有自动切档**;
2. **切了就换通道,不换人**:**信任不自动迁移**(旧的配对必须人重新批准,切回来也一样);切档**不改投任何在飞消息、也不建桥**——要跨形态说话,由模型自己用对应形态的手段说;
3. **宿主没开就报错指路,绝不悄悄退回多会话档**——「悄悄降级」正是本仓最反对的那类事故。

### 两档各能用什么(**能力矩阵**,事实源 = 父档 §2.3)

| 能力 | 多会话档 | agent-team 档 |
| --- | --- | --- |
| 跨会话投递(`team_link_send`) | ✅ 主用途 | ❌ 成员是子代理,不是可投递目标 |
| 双门批准 / 配对 | ✅ | ❌ 用不上(宿主那套没有独立信任模型) |
| 换届(`rotate`)/ 恢复(`recover`) | ✅ | ❌ 没有可换届的会话;Lead 会话没了团队就散了 |
| 看门狗(`watch`) | ✅ | ❌ teammate 不是根代理,盯不了 |
| 黑板(decisions / discipline / tasks) | ✅ | ⚠️ 仍可用,但只有 Lead 一方读写 |
| roster 身份 / 版本史 | ✅ | ⚠️ 只记「本团队是 agent-team 档 + Lead 是谁」 |
| 团队状态卡(只读) | ✅ | ⚠️ 成员部分读宿主投影(2026-09-27 已核:本部署已挂载该服务 ⇒ 探针 available=true) |
| 会话深链 / 导出 | ✅ | ✅ 与形态无关 |

**结论**:agent-team 档应叫「**单会话兜底档**」,不是「另一种平等的形态」。**多会话不可用**时才切过去,切过去就等于放弃跨会话的全部能力。

### 怎么切、怎么看(`team_link_roster`)

| 面 | 行为 |
| --- | --- |
| 切档 | `team_link_roster action=set-mode`:`mode`(闭集 `sessions` / `agent-team`,闭集外**拒绝并列出两个值**)+ `leadSessionId?`(**省略 ⇒ 默认取调用方自己的会话 id**,并在确认框正文里明示,人可见即可否决;**显式空串 ⇒ 拒绝**;非空须过**形状** `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` 与**在场校验**(能在本次会话列表快照里找到)两道) |
| **双重门**(缺一即 fail-closed、**零写入**) | ① **writer gate 原样**(`policy.writer=coordinator` 时只有现任协调者可写;空缺 ⇒ 会话路径拒绝,**设置 UI 仍是兜底**);② **人类确认框**:无确认服务 / 超时(3 分钟)/ 取消 ⇒ 拒绝且零写入 |
| **宿主探测只对「切到 agent-team」这一向** | 探测不到 ⇒ **拒绝 + 指路怎么开 + 零写入**(**绝不**把 agent-team 请求当多会话执行);切**回** `sessions` 不探测(回默认档永远可用)。探测面是宿主服务 `agentTeams`(`ctx.get`,**可选服务**:缺席只影响这一项能力)。**同一判据只探一次**:切到 agent-team 时探过的那一次**直接复用**为确认框正文里的成员名册投影。**只报现象、不替宿主下结论**:拒绝文案说的是「服务 `agentTeams` **当前不可见**(可能未装载该组合包,**也可能尚未激活完成**)」—— `ctx.get` 只返回**已激活**的 provider,一个尚未完成初始化的服务也会走到这一支,所以这里不断言「profile 没装载」 |
| 落笔 | settings 的 `teams` 行写入 `mode` / `leadSessionId`(**只改本团队行的这两个键**;`teams` 是**整份数组回写**,所以其余团队行若缺这两个键会被**补默认值**(`sessions` / 空串)、**已有值一个字不被覆盖** —— 与既有 `upsert-team` 同形,语义无变化。信任 `pairs`/`trustedSenders`/`rememberTargets` **一行不写**)+ `decisions.md` 追加**恰好一行**形态史(`形态 → agent-team(Lead=<id>)` 或 `形态 → sessions`)+ best-effort 重写 `roster.md` 镜像(与设置变更同一事务;镜像与形态史**同源** —— 都取写时复检后的那一行,确认框期间设置变了也不会「账写旧路径、镜像写新路径」) |
| 幂等 | 同档同 Lead **且账上已有这一档的形态史行** ⇒ 「已是该档」,**不弹框、不写 settings、零写入**(**首次声明也算一行**:形态史记的是「**声明过这一档**」,不是「档位发生变化」—— 所以从未切换过、第一次收到默认档声明的团队也会**补记一行**,返回体如实标「补记」);账上**缺**那一行(上一次 settings 写成功而 `decisions.md` 写失败)⇒ **补记恰好一行**再返回 —— 否则「补上路径后重发本次切换以补记」那条指路不可执行;账读不出来或没有 workspace ⇒ **不补记**并如实说明(fail-closed,不猜) |
| **镜像(`roster.md`)** | D3 起镜像也渲染 `- 形态(mode):…` / `- Lead 会话(leadSessionId):…` 两行。渲染器**永不打印 `undefined`**:缺字段 / 空串 / 非字符串按 `normalizeTeams` 的**同一套缺字段语义**落默认值(`sessions` / 空 Lead),闭集外的值按读面的规则落默认档(**降级留痕只在读面**,镜像不带);`upsert-team` **新建**时就把这两个默认值**显式写入** settings ⇒ 「settings 行 = 读面 = 镜像」三者一致(2026-09-27 真机冒烟修复轮:新建团队的镜像此前渲染成 `undefined` —— 只有**新建**这条路会,既有团队走归一化读面看不出来) |
| 切走前的提示(**提示式,不阻断**) | 确认框正文列出「**不会迁移的三件事**」:① 信任(配对 / 白名单 / 记忆目标 —— 都要人重新批准)② 成员名册(agent-team 的成员不会变成会话)③ 任务归属(宿主的任务板不跟随;台账仍是本插件的账);并列出**当前 teammate(可读则列,不可读如实说)**,加一句「请确认它们的结论已落到黑板或文件」。**不逐成员推断有没有交卷** |
| 看形态 | `team_link_roster action=get` 的**形态段**与状态卡第 **⑦ 段**:形态 + Lead + 成员名册**投影**(读时现算,**一行不落 settings**;来源文案:可读时写 `来源:宿主 agentTeams 投影(本部署可读)`;不可读时**按分支**取首行 —— 只有**服务缺席**才说 `成员名册不可读(本部署未提供该投影)—— 仍可读 Lead 与形态`,其余四条分支(缺方法 / 无会话身份 / 调用方非成员 / 读取抛错)用中性首行 `成员名册不可读 —— 仍可读 Lead 与形态(哪一条读不到,见下一行的原因)`,真正的原因由紧随的`(不可读原因:…)`行点名)+ **完整能力矩阵** + 那句结论 |
| **形态诊断行** | 本工作区**除自己外没有其他可达会话** ⇒ 读面附一行「⚠ 多会话通道看起来不可用(列表里没有其他会话)—— 你可以让协调者切到 agent-team 档(team_link_roster action=set-mode)(只提示:本插件绝不自动切档…)」。**只提示,绝不自动切**;**没有会话身份时不判** —— 判据是 `null`(不判、不猜,与「列表读不到」同一处置),而不是拿一个猜出来的工作区当结论 |

**红线(形态批 §5)**:**只读宿主**(不 spawn / 不派活 / 不改宿主任务状态)· **不悄悄降级**(写路径刺眼报错、读路径如实降级,**两条路不许混用**)· **信任不自动迁移** · **双重门 fail-closed** · **指针不拷贝**(只存 Lead 会话 id)· **切档永远由人触发**。**明确不做**:不代理宿主派活 / 不读子代理日志(交卷只做提示式)/ 不自动切档 / 不做跨形态改投。

---

## 四、团队:roster 与黑板

### roster(`team_link_roster`)

`teams` 键是身份层的事实源:团队 → 角色 → 会话,带版本史。

```yaml
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:` 段下写入(键名与[第六节](#六策略配置)一致):

```yaml
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:` 段在**第一次真正写入**后落盘——注册失败或迟迟未挂载会在日志里留痕,见[第七节](#七服务获取与留痕037-的关键修复)。

### 黑板(`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](docs/team-ledger-and-mode-design-2026-09-26.md)):

```text
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 行**,逐任务一行:

```text
--- 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`**(把协作纪律写成条款,而不是靠每个会话自觉 —— 四条都只用**今天已有**的能力,零新增机制):

```text
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`](docs/team-ledger-and-mode-design-2026-09-26.md) §1 与 §2.5。

---

## 五、团队换届 rotation

团队换人不是「改个 current」:新任会自动继承一条**绕过两道批准门**的免确认通道(`pairs`,连接收方的显式 reject 策略都覆盖),所以交接被拆成两个阶段 + 一次性令牌 + 域限定迁移 + 可回退的临时信任。

```mermaid
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 不动、信任零改动) | 死亡绝大多数是重载/重启假象 |
| 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` 键内)

```yaml
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 键(重启即失)`),跟着实际落点走。

| 键 | 类型 | 说明 |
| --- | --- | --- |
| `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}[]` | 跨会话看门狗注册(到点自动清理;手改时缺字段的条目会被丢弃,不会让整个命名空间失效) |
| `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`](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`](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](#依赖声明宿主包一律走-peerdependencies)」一节。

### 方式一:本地目录 + 热装配(开发常用)

依赖通过 junction 复用 DSH 检出目录的 `node_modules`(免下载):

```powershell
$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 加载的就是你正在改的那份代码,**改完只需重启**,没有任何拷贝步骤:

```jsonc
// 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=?` |
| `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) |
| `agentTeams` | §4.2 / §4.3 的**团队形态**:成员名册**投影**(读面)与「切到 agent-team」的**能力探测**(写路径)—— **只读宿主**:只调 `tryMembership(agent)` / `listMembers(agent)`,任何宿主写方法都不调用 | `ctx.get` 取用(**不在** `inject` 数组里,模块级 `inject` 恒 4 项);服务缺席 ⇒ 读面**如实标注**「成员名册不可读(本部署未提供该投影)—— 仍可读 Lead 与形态」并照给能力矩阵(服务在场而读不到的四条分支走**中性首行** + 原因行,见[团队形态](#团队形态多会话档--agent-team-档)一节);写路径切**到** agent-team **拒绝 + 指路怎么开(通用表述:按**当前活动 profile** 的 `dsh.profile.bundles` 自查)+ 设置 UI 兜底路 + 零写入**(切**回** sessions 不探测) |

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` → **1314**(failed: 0);`node client-half.test.mjs` → **310**(failed: 0)。(`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 换代」一节。)(`1300 = 1264 + 35 + 1` / `299 = 269 + 30`:2026-09-27 **C 批**(客户端团队面板 + 形态可视化 + 🔵 登记 4 条 / 本批执行 3 条,① 已在上一批修完)新增 **65 条**断言,另**改写既有期望值 4 条**(host 3 条路由计数断言、client 1 条段落类序断言 —— 新增一条路由 / 一个分区逼出来的,断言照旧严格);红相读数分三段:C1 `host 1290 (failed: 23)` / `client 275 (failed: 4,四条命名守卫=覆盖缺口类,未执行正相断言)`、C2 `host 1295 (failed: 4)` / `client 299 (failed: 1)`、C3 `host 1299 (failed: 3)`;原始读数见 `docs/verification-log.md` 末节。)(`1203 = 1193 + 10`:2026-09-27 真机冒烟修复轮 L1/L2 新增 10 条宿主断言;该轮聚合红相 `1203 (failed: 17)` = **7 条新断言真红** + **10 条改写期望值**;另 3 条覆盖缺口类如实标 ★负相 —— 原始读数见 `docs/verification-log.md` 的「真机冒烟修复轮」一节。)(`1262 = 1255 + 7`:2026-09-27 代码评审第 2 轮新增 **7** 条宿主断言,聚合红相 `1262 (failed: 4)` = **4 条新断言真红** + 3 条控制项 ★ 负相本就绿;原始读数见同档的「代码评审第 2 轮」一节。)(`1264 = 1262 + 2`:2026-09-27 代码评审第 3 轮新增 **2** 条宿主断言,聚合红相 `1264 (failed: 2)` = **2 条新断言真红**(控制条件并入同条断言,无单独负相项);原始读数见同档的「代码评审第 3 轮」一节。)(`1300 = 1299 + 1`:2026-09-27 **C 批分歧审计修复轮**(🔵#2/#3/#4/#5)新增 **1** 条宿主断言 —— 🔵#5 的 null 判据:红相 `1300 (failed: 1)`(行为半在**本轮修复前**已成立 ⇒ ★ 负相不冒充;真红的是**判据面**与「行为修正」**标记**两半)、绿相 `host 1300 (failed: 0)` / `client 299 (failed: 0)`;原始读数见同档的「C 批分歧审计修复轮」一节。)(`1306 = 1300 + 6` / `305 = 299 + 6`:2026-09-27 **C 批代码评审修复轮**(2 🟡 + 4 🔵)新增 **12** 条断言(host 6 / client 6)—— 🟡#1 面板类名↔样式规则 2 条 · 🟡#2 翻页不回卷 3 条 · 🔵#3 降级句随页 1 条 · 🔵#4 派生行团队归属 2 条 · 🔵#5 `wellFormed` 2 条 · 🔵#6 React `key` 完备 2 条;红相 `host 1306 (failed: 5)` / `client 305 (failed: 4)`(**实测红 9 条**;另 2 条为控制/对照项 ★ 负相本就绿、1 条为实施后补强**未取得红相读数**,均如实标注),绿相 `host 1306 (failed: 0)` / `client 305 (failed: 0)`;原始读数见同档的「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`](docs/verification-log.md)**——0.3.1 → 0.4.0 每一轮的审计变异、修复前必红读数,以及本仓库的「变异验证」验收文化。本节的定位是**面向读者**:怎么跑、现在多少条、红线是什么。

**红线由哪些断言把守**(宿主套件内的源码级与运行时锁):模块级 `inject` 恒 4 项;投递 `source` 恰三成员;**不引入任何新的会话日志事件类型**;`PolicyConfig` 的键集;`writerGate` 函数体逐字节不变。
## 设计文档索引

| 文档 | 内容 |
| --- | --- |
| [`docs/team-upgrade-design-2026-09-17.md`](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`](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`](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`](docs/client-panel-batch-design-2026-09-27.md) | **C 批设计**:客户端团队面板(六段只读 + 落点行)· 形态可视化(Lead/成员/多会话徽标)· 🔵 收尾(**登记 4 条 / 本批执行 3 条**,① 已在上一批修完);判据 **W1–W9**(§3.0 的跨半边取数通道落地为**只读路由** `GET /team-link/panel`,与导出路由同一道栅栏 —— 新增判据 **W9**:面板读路径零写)|
| [`docs/policy-persistence-design-2026-09-27.md`](docs/policy-persistence-design-2026-09-27.md) | **策略持久化自持化**(设计):三档落点 → 四档、原子写、晚挂折叠、可核落点行;判据 U1–U8 |
| [`docs/poster/plugin-poster.html`](docs/poster/plugin-poster.html) | **海报**(零截图 · 自制风格 · 已脱敏):三层结构 / 与宿主 Agent Teams 正交 / 实战数据 / 纪律 |
| [`docs/comparison-agent-teams-2026-09-26.md`](docs/comparison-agent-teams-2026-09-26.md) | **对比(事实核对,不是设计)**:本插件 vs DSH 内置 Agent Teams 的 14 项逐项对照、三条结构性差异、双方优势、两套共存时的边界;出处分 `[实测]` / `[README]` / `[会诊]` 三级,§9 是**未复核**的源码级线索 |
| [`docs/observability-batch-design-2026-09-26.md`](docs/observability-batch-design-2026-09-26.md) | **可观测批(A 批,2026-09-26)**:读窗扩展(`readIds` 点名读 / `offset` 分页 + 按工具分账的成本不变量 + 末尾「未读 Y 行 + 怎么读」提示)· 只读**团队状态卡** `team_link_status`(六段 + 反面预警注记;**第 ⑦ 段「形态」由形态批追加**)· 台账**收件视图** + **派生回执**;判据 U1–U14,§3 的成本模型与 §8 的假设 A1–A5 逐条实测,读数见 [`docs/verification-log.md`](docs/verification-log.md) |
| [`docs/team-ledger-and-mode-design-2026-09-26.md`](docs/team-ledger-and-mode-design-2026-09-26.md) | **「台账 + 形态」设计(2026-09-26 已拍板 · 第 1 步「只追加台账 `tasks.md`」已实施,判据 U1–U18;第 2 / 4 / 6 步已随「可观测批(A 批)」一并实施;**第 3 步(形态开关)与第 5 步(teammate 交卷提示)已随「形态批(B 批)」实施**)**:只追加台账 `tasks.md` + `meta.ref` 映射(把「账」搬出上下文、把「事」留在上下文)+ 团队形态开关(多会话 ↔ agent team 单会话兜底档)+ 只读团队状态卡 + 四条纪律约定;含 §6 拍板结论与 §7 实施顺序 |
| [`docs/team-mode-batch-design-2026-09-26.md`](docs/team-mode-batch-design-2026-09-26.md) | **形态批(B 批,2026-09-26)**:团队**形态**字段(`mode` / `leadSessionId`,只落在 settings)· `team_link_roster action=set-mode`(**第五个封闭动词**:宿主探测 + 双重门 + Lead 校验与默认 + decisions 恰好一行 + 幂等)· 读面**形态段**(形态 + Lead + 成员名册投影 + 完整能力矩阵 + 形态诊断行;roster get 与状态卡 ⑦ 段同源)· **teammate 交卷提示**(提示式、不阻断);判据 U1–U14 + U11b,§8 的假设 A1–A4 逐条实测,读数见 [`docs/verification-log.md`](docs/verification-log.md) |
| [`docs/team-upgrade-research-2026-09-17.md`](docs/team-upgrade-research-2026-09-17.md) | 调研:一次 16+ 小时真实多会话联调的复盘,与升级提案(**其 §5 已被设计取代**,以设计文档为准) |
| [`docs/consult-minutes/`](docs/consult-minutes/) | 多模型会诊纪要(含裁定层:逐条采纳/不采纳与理由、分歧父侧裁定、教训、不可验清单) |
| [`docs/verification-log.md`](docs/verification-log.md) | **验证账本**(证据,不是说明书):0.3.1 → 0.4.2 逐轮的红相/绿相读数、审计变异矩阵、以及每次真机验证的原始取证(含 0.3.7 那次「静默失效一整天」的完整调试历程) |

---

## Changelog

完整变更史见 **[CHANGELOG.md](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`](docs/verification-log.md)。
---

## Credits

Fork 自 [PwnKY/dsh-session-link](https://github.com/PwnKY/dsh-session-link)——深链复制、`/s/<id>` 打开器、深链上下文注入均保留自上游,感谢上游工作。

## License

MIT

Install

dsh plugin --profile web add github:shenhuanageshei/dsh-team-link

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source