DeepSeek Harness Hub
← 返回列表

会话互联导出shenhuanageshei/dsh-team-link

DeepSeek Harnessspec-screened在 GitHub 查看 ↗
未验证

DeepSeek Harness DSH 的「多会话协作」插件 —— 让同一个 DSH…

尚未跑自动兼容性验证,可查看页面内的依赖与入口分析。 · 最近上游提交 2026/9/19 · 已提供中文文档

DeepSeek Harness(dsh)的会话深度链接 + 完整会话导出(markdown/JSON)+ 经批准的跨会话消息传递与配对。

综合分
30
GitHub 分
30
用户评分
★ Stars
0
周下载量
安装插件(需先安装 dsh CLI 引擎:npm install -g @deepseek-ai/dsh)
dsh plugin --profile web add shenhuanageshei/dsh-team-link
该插件未发布到 npm,走 GitHub 源安装(pnpm 若拦截 prepare 脚本,按其提示在 pnpm-workspace.yaml 的 allowBuilds 中放行后重跑)
数据截至 2026/9/20(元数据每日更新 · 实装验证按队列轮转,单条结论的验证时间见上方)
依赖的 DSH / Cordis 模块
@deepseek-ai/cordis@deepseek-ai/dsh-client-locale@deepseek-ai/dsh-client-ui-conversation@deepseek-ai/dsh-session-reference@deepseek-ai/dsh-tools
用户评分
还没有人投票,来当第一个
订阅周报,不错过优质插件更新
每周一封 · 高评分插件 + 新用户活动

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
version
license

Fork 自 PwnKY/dsh-session-link——深链复制、/s/ 打开器与深链上下文注入保留自上游;本仓库在其上长出了完整的多会话协作层。

目录

- 这是什么:三个痛点
- 架构
- 快速开始
- 工具一览
- 一、跨会话消息
- 二、会话可视:列表 / 深链 / 导出
- 三、跨会话看门狗
- 四、团队:roster 与黑板
- 五、团队换届 rotation
- 六、策略配置
- 七、服务获取与留痕(0.3.7 的关键修复)
- 八、兼容性与字符串安全
- 九、安装
- 十、测试
- 设计文档索引
- Changelog
- Credits · License

这是什么:三个痛点

在一个 DSH 实例里开多个会话干活(一个协调者 + 若干 worker)时,会话之间默认是隔离的:

| 痛点 | 表现 |
|---|---|
| 看不见 | 不知道别的会话在干嘛:跑着还是卡了?在等人还是已经静默十几分钟? |
| 说不上话 | 没法把 A 会话的结论交给 B 会话继续;想归档一个会话只能翻 UI |
| 交不了班 | 会话上下文会疲劳,但把协调者换成另一个会话,意味着信任关系、团队身份全要人工重来 |

本插件补上这三层能力:

| 能力 | 说明 | 入口 |
| --- | --- | --- |
| 🔗 会话深链 | 复制 dsh://session/,粘贴到任意会话即注入该会话只读快照(上游功能) | 会话头部按钮 / 粘贴链接 |
| 📋 会话列表 | 列出同工作区其他会话:主题、运行状态、最近消息摘要,以及活性信号行(verdict 五态 + goal 状态 + 静默时长 + 读数时效戳) | team_link_list_sessions |
| ⬇ 会话导出 | 全量事件导出为 markdown(可读)+ JSON(无损) | team_link_export / 会话头部 ⬇ 按钮 |
| 📨 跨会话消息 | 投递给另一会话,空闲目标自动唤醒为新回合;返回带 busy 预判;目标没活动代理时以 ❌ 未投递 开头并列出同工作区存活会话 | team_link_send |
| 📣 广播 fan-out | 一次投多个目标:会话 id / team:/ / team:/(全队,仅现任协调者);任一目标未投递则返回首行即 ❌ 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(整文件替换,乐观锁) | team_link_team_read / team_link_team_append |
| 🔄 团队换届 | 两阶段交接:一次性令牌 + 域限定信任迁移 + 退役者对称吊销 + 24h 可回退 | team_link_rotate |

一条设计红线:绝不把「没投出去」说成「已发送」

这个插件里所有对外文案都遵循同一条纪律——拒绝要刺眼、降级要留痕、不确定要如实标注。三条具体体现(都是生产事故换来的):

- 目标没有活动代理 → 首前缀是 ❌ 未投递,并列出同工作区其他存活会话(有人真把「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"]mermaid
T["11 个工具list / export / send / watchroster / team_read / team_append / rotate"]
R["HTTP 路由GET /team-link/export"]
W["看门狗巡逻定时器+ 换届到期清扫"]
DL["深链解析(上游功能)dsh://session/<id>"]
ST["policy storesettings 命名空间 team-link"]
end
subgraph Client["浏览器半边 · lib/client.js"]
CARD["📡 消息卡片chat.node keyed slot"]
BTN["会话头部按钮复制深链 / 导出"]
end
end
S1["会话 A(协调者)"] -->|team_link_send| T
S2["会话 B(worker)"] -->|team_link_| T
T -->|steer / followup| S2
T --> ST
ST -->|持久化| YAML[("profile/settings.yamlteam-link: …")]
T -->|best-effort 镜像| BB[("<workspace>/team/<name>/roster.md · decisions.md · discipline.md")]
R --> CARD
BTN --> R

要点:

- 宿主半边拥有全部工具、/team-link/export 下载路由、看门狗巡逻定时器与换届到期清扫;所有模型可见输出都过 wellFormed()(字符串安全,见第八节)。
- 浏览器半边只做两件事:把跨会话消息渲染成醒目卡片(影子替换 chat 包默认的灰字行,非本插件消息委托回原渲染器),以及会话头部的「复制深链 / 导出」按钮。
- 状态的事实源是 settings(命名空间 team-link);/team// 只是人可读镜像,写入失败只告警、不回滚设置(settings 始终是事实源)。
- 团队身份与信任关系(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 播种为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 |
| 广播 | 「广播全队:」(仅现任协调者) | send + targets=["team:/"] |
| 点对点 | 「发给 worker1:」 | send + targets=["team:/worker1"] |
| 按优先级/性质发 | 「以 P0 裁决回复 W1,引用我上条消息」 | send 的 meta 信封 |
| 记账 | 「这条记入裁决账本」 | team_link_team_append(decisions) |
| 改纪律 | 「更新纪律条款」 | team_link_team_append(discipline,带 baseHash) |
| 盯人 | 「盯住 W1/W2,静默 15 分钟叫我」 | team_link_watch |
| 交班 | 「我下岗,让 session-X 接任」 | team_link_rotate(两阶段) |
| 归档 | 「导出这个会话」 | team_link_export / 头部 ⬇ |

工具一览

| 工具 | 一句话 |
| --- | --- |
| team_link_list_sessions | 同工作区其他会话 + 活性信号行(verdict 五态 / goal / 静默时长 / 读数时效戳) |
| team_link_export | 任意会话全量导出 md + JSON |
| team_link_send | 跨会话投递(单目标或 targets 广播 ≤8);meta 信封;返回带 busy 预判;发送方自己那一行渲染成卡片(§10.1 A/D) |
| team_link_watch | 给自己注册跨会话看门狗(register / list / clear) |
| team_link_roster | 团队身份注册表(get / upsert-team / set-role / retire) |
| team_link_team_read | 一次读齐 roster + decisions 末 20 条 + discipline 全文 + 两个 baseHash |
| team_link_team_append | 写黑板:decisions 只追加 / discipline 整文件替换(乐观锁) |
| team_link_rotate | 两阶段换届(prepare / claim),一次性令牌 + 域限定迁移 |

team_link_export 走 sessionQuery 读会话;team_link_send 走 agents 投递。两者都不需要目标会话正在被 UI 打开——但目标必须有活动代理(见第一节的 A4 诚实声明)。

一、跨会话消息

投递语义:steer 还是 followup
mermaid
flowchart TD
START["team_link_send"] --> Q1{"目标是同一工作区的存活根代理?"}
Q1 -->|否| NOAGENT["❌ 未投递+ 列出同工作区存活会话+ 三条核对提示"]
Q1 -->|是| Q2{"目标当前状态?"}
Q2 -->|运行中| STEER["steer在步边界注入当前回合返回追加「已运行 N 分钟」"]
Q2 -->|空闲| FOLLOW["followup唤醒为新回合消息立即可见并触发响应"]

- 目标运行中 → steer:在步边界注入其当前回合;
- 目标空闲 → followup:唤醒目标会话,作为新回合处理(立即显示并触发 LLM 响应,不会静默排队);
- 目标没有活动代理 → 拒绝,且拒绝文案自带自愈线索:

- 首前缀 ❌ 未投递(不许被读成「已发送」);
- 保留原句「目标会话  没有活动代理」;
- 新增同工作区存活会话列表:每行 id(运行中/空闲),上限 10 个,超出时注明「共 N 个,仅列前 10 个」;
- 新增核对提示:对照 id(最常见成因是转录错位)/ 刚重启 DSH 时在侧边栏打开目标会话一次使其恢复为活动代理 / 先调 team_link_list_sessions。

该列表是纯 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-,UI 卡片靠它把自己的中继与上游相邻代理消息区分开。

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

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

广播 fan-out(targets)

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

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

team:/ 之所以收得这么紧:协调者的价值部分在于策展每个 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 语义、不给分钟数;
- 目标空闲 → 保持原文案(已唤醒为新回合)。

消息卡片(浏览器侧)

接收方 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 + detail」,运行中的目标带 busy 分钟数。不渲染标题/时间/正文/汇总 |
| D | 会话流顶层 | 本插件自己的 uiConversation definition(kind team-link-send)+ 同 kind 的 conversation.chat.node(priority: -90) | 标题 + 发送方/时间 + 正文 + 汇总计数。不渲染逐目标明细行(目标身份归 A 的行)。与接收方的 key: "context" 卡片kind 不同,并存不冲突 |

数据来源是官方载体,不是解析返回文本:宿主半边给 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:/ 就展开成该队全部在册存活成员,所以合法的行数可以超过 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 仍恰三成员;模型上下文无新增消息。

已知边界(如实声明):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 / 主题 / 最近动态一律标为未判定。
为什么要有界:一行 surface = 一次冷日志解压 + 一次表面投影。真实工作区实测(26 个会话)逐个串行读满 LIST_LIMIT(50)会直接超掉 60s 工具预算——0.3.5 修的就是这个(修前超时、修后约 15s)。这里选择有界 + 并行 + 如实标注,而不是编一个没读过的判定。

窗口内的字段:

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

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

flowchart TD
SIG["读信号:代理状态 · goal phase/activation · 静默时长"] --> D{"有存活代理?"}
D -->|无| DEAD["dead会话已关闭:只有用户能处理"]
D -->|有| RUN{"运行中?"}
RUN -->|是| LONG{"当前回合 > 30 分钟?"}
LONG -->|是| LR["long-running可能卡住,值得看一眼"]
LONG -->|否| OK1["ok"]
RUN -->|否,空闲| G{"goal 状态?"}
G -->|"active + armed"| OK2["ok它有自己的续跑节拍"]
G -->|"active + disarmed"| GD["goal-disarmed ⚠️根因级静默:activation 不持久化重启 / max-tokens 结束 / agent error都会落到这里,且不会自愈"]
G -->|"paused / blocked / complete"| OK3["ok已被解释的静默(在等人类决策)"]
G -->|无 goal| S{"静默 > 10 分钟?"}
S -->|是| SI["silent-idleP1 场景:协调者在等 worker 回报"]
S -->|否| OK4["ok"]

goal-disarmed 是这个插件最想让你看见的状态:它看起来「空闲」,其实是根因级静默——goal 还是 durable-active,但驱动器不会再排队,除非人类(经模型转告后)显式 resume。插件绝不代调 goals.resume(那是人类授权门),只负责把状态摆出来、并给出合规的恢复回路。

读数带时间戳:每行行尾是 (读数 YYYY-MM-DD HH:mm:ss,>2min 作废)——活性是快照,超过 2 分钟须重新读。

深链(上游功能)

复制 dsh://session/,粘贴到任意会话即注入该会话的只读快照作为上下文。这是上游 dsh-session-link 的能力,本仓库保留其解析行为不变(register-protocol.ps1 / dsh-open.cmd 负责协议注册与打开)。

team_link_export

任意会话全量导出:markdown(人可读,含信封首行)+ JSON(无损事件流)。同一个导出能力也挂在 GET /team-link/export?session=&format=md|json(会话头部 ⬇ 按钮消费),路由与工具共用同一个文件名安全不变式(fileSafeSessionId())。

三、跨会话看门狗

看门狗解决的是「我(协调者)在等 worker,但没人叫醒我」——它反过来:盯住别人,别人失联且我空闲时叫醒我自己。

tick 策略

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

| 目标 goal 状态 | tick? | 理由 |
| --- | --- | --- |
| armed + active | 永不 | 它有自己的续跑节拍,tick 只稀释节奏 |
| active + disarmed | 立即(不等静默阈) | 根因级静默态;载荷带诊断与合规恢复回路(转告用户 → 用户授权 → 模型自己 update_goal(action:"resume")) |
| paused / blocked / complete | 不 tick | 在等人类决策 / 已完结 |
| 无 goal | 静默超阈才 tick | P1 场景 |
Observer side: When self is running or self is armed-active, do not tick; when self's agent does not exist, do not tick and do not change registration (that session is already agent=not running in the liveness row, and watch list separately marks "observer=dead"; the registration is retained until TTL expiry and self-clears—once the agent returns, delivery naturally resumes).

Limits and implementation conventions

- register can only register for itself (observer = exec.agent.id), and targets cannot include itself (self-reference is equivalent to a disguised self-tick timer);
- A single session can have at most 3 registrations; silentMinutes >= 10 (default 10); intervalMinutes >= 5 (default 5); ttlHours  }; message id prefix slp-wd-;
- The body is a plugin constant template, with only status fields interpolated (target id / reading time / silent duration)—registration parameters do not enter the body, and registration cannot smuggle prompts into the observer's next turn;
- Debounce: for the same target, "at most one tick per silent period," and at least one patrol interval between two ticks. The debounce state is an in-process Map, not persisted—forgotten on restart; better one extra tick than persistent state that could misjudge;
- The patrol timer is cleaned up together with plugin dispose (ctx.effect).

Honest declaration (A4)

The watchdog can only remind living observers. When either the observer or the target is closed, no mechanism can wake it—the plugin only marks dead on the signal surface for the user to handle. "Maintaining the rhythm of live sessions" is the real coverage; "recovery from disconnection" is not.

IV. Team: roster and blackboard

roster (team_link_roster)

The teams key is the source of truth for the identity layer: team → role → session, with version history.

teams:
- name: night-shift                # [a-z0-9-]+, unique within the workspace (also the blackboard directory name)
createdAt: 1700000000000
workspace: D:/work/night         # captured from that session's agentCwd when the team is first created, not rewritten afterward
policy: { writer: coordinator }  # coordinator | any
roles:
- role: coordinator            # conventional role name; custom roles (reviewer, etc.) are created on demand by set-role
current: session-abc         # incumbent; null = vacant
pending: null                # successor slot for M4 rotation; M2 only preserves it as-is
history:                     # version history: one entry per term, until: null means still in office
- { session: session-old, from: 1700000000000, until: 1700009999999, note: handover }

| action | effect | write permission |
| --- | --- | --- |
| get | Summary of all teams; when team is specified, gives details (including pending and the full version history) | Any session can read, no gate |
| upsert-team | Create (default policy.writer=coordinator, workspace taken from the calling session's agentCwd, and the calling session is seeded as the team's current coordinator—creation is claiming) or idempotent update | Existing teams pass the write-permission gate; repeated calls do not reset roles / version history / createdAt, nor seed again |
| set-role | current replacement + version history append: the old incumbent's entry records until=now (with note), and the new incumbent's entry opens with until: null; if the role does not exist, this specification creates it. When the specified session is exactly the successor of an in-flight rotation pending, that token is invalidated on the spot (identity has been explicitly changed by this operation, and claim will report "no pending") | Passes the write-permission gate; does not migrate pairs—trust migration is an exclusive action of rotation |
| retire | Sets current to empty (vacant) + records retirement in version history (until=now, with note). Retirement itself does not touch trust data; afterward it pops one confirmation dialog listing all pairs / trustedSenders / rememberTargets still pointing to that session, and only deletes them if "clean up" is chosen | Only the current coordinator session or user-initiated (regardless of policy.writer) |

Write permission and "creation is claiming"

- coordinator (default): only the current session of the coordinator role can write (compared against exec.agent.id);
- any: any session can write;
- Reads are always open; policy itself can only be changed by the user (there is no policy slot in the tool parameters).

A deadlock fixed in 0.3.7: the creation path of upsert-team should never have passed the write-permission gate (otherwise no one could create the first team), but set-role necessarily passes the gate—and the gate rejects all session paths when "writer=coordinator and the incumbent is vacant." Thus in older versions: the model could create a team, but could never write in the first coordinator, and the team became read-only as soon as it was obtained. Now the creation path directly seeds the calling session as the current coordinator, so the team can dispatch work / write to the blackboard as soon as it is created, without manually editing any configuration. Vacant rows written by hand are still fully rejected (that is the state explicitly expressed by the user).

Self-service bootstrap (manual setup)

The normal flow does not require this step. Manually editing settings.yaml is needed only in two situations: ① to replace the incumbent of an existing team with another session, or to rescue a team that was manually written as vacant (in this case the write-permission gate will block all session paths, and the tool cannot get through); ② to pre-lay out teams when the user is not in the model loop.

Directly edit the profile's settings.yaml, and write under the team-link: section (key names consistent with Section VI):

team-link:
teams:
- name: night-shift
createdAt: 1700000000000
workspace: D:/work/night
policy: { writer: coordinator }
roles:
- role: coordinator
current: session-abc         # 现任会话 id;null = 空缺(会话路径会全拒)
pending: null
history:
- { session: session-abc, from: 1700000000000, until: null }

- 保存即生效:dsh-settings-file 的 watcher 会热加载(2026-09-18 实测:外部删除 team-link: 段后,team_link_roster action=get 立刻回到 0 团队);若个别环境不触发,重启 DSH 即可确定性地重新加载;
- 只写 current 不写 history 也能生效(任期起点退回 createdAt 显示);补一条 until: null 的任期记录才让版本史自洽;
- 名字不合 [a-z0-9-]+ 的团队行、没有 role 的角色行会被归一化丢弃,不会让整个命名空间失效(其余行照旧生效);
- 该命名空间只有在插件成功注册后才存在;settings.yaml 里的 team-link: 段在第一次真正写入后落盘——注册失败或迟迟未挂载会在日志里留痕,见第七节。

黑板(team_link_team_read / team_link_team_append)

以 team.workspace 为根:

/team//roster.md       # roster 镜像(插件同一事务内 best-effort 写;失败只告警,settings 是事实源)
/team//decisions.md    # 裁决账本:只追加,每行 seq | ISO 时间 | author-session-id | 正文
/team//discipline.md   # 纪律条款:整文件替换,必须携带 team_read 返回的当前 baseHash

- team_link_team_read(team) 一次读齐:roster 概要 + decisions 末 20 条 + discipline 全文 + 两个文件的 baseHash(sha256 前 16 位十六进制)。文件不存在按空处理并如实标注(含「空内容哈希」);
- team_link_team_append(team, file, line, baseHash?):decisions 只追加(无需 baseHash,正文必须单行);discipline 整文件替换(baseHash 缺失或不匹配即拒绝并要求重新 team_read)。两者都受单行 500 字符上限(按码点计);file 是白名单枚举,任何路径形状都会被拒;
- 黑板没有写权限门(任何会话可写):写入者身份记在 decisions 行的 author 字段里,透明可审计;discipline 的整文件替换靠 baseHash 串行化。

实现约定(设计未明说、按既有约定裁决的细节):

- workspace 只在团队首次由会话创建时捕获,过后永不改写。用户手工建的行若 workspace 为空,第一次会话侧 upsert-team 会补记它(唯一一次例外,否则该团队的黑板永远没有根);
- 版本史按任期记:一段任期一条,until: null 标在任;set-role / retire 关掉旧条目并写 note。首次指派(无旧任)把 note 记在它打开的那条上,避免 note 丢失;
- 镜像里时间戳统一 YYYY-MM-DD HH:mm:ss(本地);decisions 行里的时间是 toISOString()(UTC 带毫秒),便于排序与外部消费;
- 没有会话身份的写入仍被接受(黑板无门),author 记 unknown——不编造身份;
- decisions 写入是读-算 seq-追加(appendFile,不重写正文):并发追加最坏只是两条同 seq,不会丢行。

五、团队换届 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、错峰默认、半自动兜底)。

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,它就静默关闭。

内部通知

四种通知(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 服务不可用时降级为进程内记忆并留痕):

| 键 | 类型 | 说明 |
| --- | --- | --- |
| 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}[] | 团队 roster 与换届记账。name 必须 [a-z0-9-]+(它是黑板目录的路径段);workspace 是团队首次创建时捕获的会话工作目录、也是黑板根;手改时非法团队名/无名角色会被丢弃 |

七、服务获取与留痕(0.3.7 的关键修复)

这一节值得单独读:0.3.7 修的是一次「静默降级了整整一天没人发现」的故障。

病灶:ctx.get 是时点快照

settings 与 webServer 都是运行期取的服务(不在 inject 数组里,服务缺失时插件完整降级)。但 cordis 的 ctx.get(name, strict = true) 只返回提供方 fiber 已 active 的服务:

_getImpl(name, strict = true) {
const impl = key && this.store[key];
if (!impl) return;
if (strict && impl.fiber.state !== 2) return;   // ← 只认已激活的提供方
return impl;
}

而 settings provider 要先完成 [Service.init](读 settings.yaml → publish)才 active。插件 apply 的那一刻它可能还没就绪——于是旧版本在这里一次性取服务,取不到就静默退回进程内存引擎:此后无重试、无日志,teams / watchdogs / pairs / rotation 全部不落盘,每次重启清零。

当时的实测取证:team_link_watch register 返回成功、list 可见条目,但 profile/settings.yaml 的 mtime 与全文在写入前后毫无变化;日志里也从未出现本插件的任何 warn(另一条注册失败路径必留 warn,故也被排除)。盘上从来没有 team-link: 段——自 0.2.x 起信任数据从未落盘,所以「改名迁移已完成」这个结论当时是错的。

修法:可选有序注入 + 惰性重试 + 留痕
mermaid
sequenceDiagram
autonumber
participant C as 插件 apply
participant S as settings provider fiber
participant L as 日志
C->>S: ① 快路:ctx.get("settings")
Note over S: provider 需先完成 [Service.init](读 settings.yaml → publish)才 active
alt 已 active(或同步 stub)
S-->>C: service
C->>L: info · policy store attached to settings namespace "team-link"
else 尚未 active
C->>L: ⚠️ warn · settings not active at activation (not yet active | no register())
C->>C: ② ctx.inject(["settings"], cb) —— 可选有序注入,非硬依赖
S-->>C: 服务转 active → 回调 attach
C->>L: info · policy store attached …
C->>C: 内存窗口并入 → 改名迁移
C->>L: info · post-attach policy chain finished — …
end
Note over C: ③ get()/update() 每次发现未挂载就惰性重试 ctx.get("settings")每个未挂载窗口有且仅有一行 warn

1. 立刻试一次(提供方已 active,或有同步 stub:走快路,不引入任何异步延迟);
2. 没拿到 → 留一行 warn,并登记 ctx.inject(["settings"], cb):provider 转为 active 时回调 attach。这是可选有序注入,不是硬依赖——服务永远不出现时插件照常加载并降级;ctx.inject 本身不可用时再留第二行 warn;
3. 运行期惰性重试:get() / update() 每次发现未挂载就再试一次;成功即挂载并记一行 info。失败不重复告警——「未挂载告警」有两条到达路径(激活时未 active / active 但 register() 抛错),二者共用同一个一次性门,故一个未挂载窗口有且仅有一行 warn。

一次性改名迁移(session-link-pro → team-link)随之从 apply 当场移到 attach 之后执行(在 ③ 未修时它必然是空操作)。

明确不采用的做法:把 settings / webServer 加进 inject 数组。inject 是硬依赖——依赖缺失时 cordis 令整个插件 fiber 不激活,与「服务缺失时插件完整降级」这条红线相抵;而 ctx.inject 与 inject 在确定性上等价(同样等 provider 完成 [Service.init]),故取零功能回归者。

三个配套细节

启动窗口的数据一致性(防御性冗余):attach 之前写进去的东西只会落在进程内存里,而这个窗口理论上不可达(那时还没有活动代理能调工具)。万一真发生,attach 时会把这些内存写入按与改名迁移同形的规则并入设置命名空间(仅当设置侧仍是默认值;settings 始终是事实源),并留一行 warn——不静默丢写。并入先于改名迁移执行,迁移的「当前命名空间已在用」判据因此看到的是最终状态。

provider 生命周期归属:晚挂拿到的 scope 与其注入 fiber 同生共死——挂载时用 owner.effect(() => () => detach(), …) 把释放挂到「取到服务的那个上下文」的 fiber 上(与 webServer 站点 target.effect(() => webServer.register(…)) 同一条归属规则)。provider 的 fiber 被 dispose(配置变更 / 插件重载 / teardown)时,scope 归零并记一行 info,store 回到未挂载态,provider 回归时由惰性重试重新挂载。此前这里只有捕获、没有归属:scope 非 null 却已死,于是 update() 每次都抛错、而 get() 静默改答陈旧内存——读写长期不一致。快路(宿主自身 ctx)无需额外处理:那里的 owner 就是本插件自己的 fiber,store、工具、巡逻定时器与这条 effect 同生共死。

事后链留痕:attach 之后的两步(内存窗口并入 → 改名迁移)各自返回结论,attach 结束时统一记一行 info:

挂载后策略链已完成 — 内存窗口:;
旧版迁移:

「被拒」不再与「根本没有旧命名空间」同形;内存窗口旗标在结论记录后清零(所以后续窗口不会把同一份已解决的内存态再并入一次),只有 fold failed 保持置位——那些写入确实仍在内存里,下一次挂载必须重试。

红线与回归锁

红线:任何「未能立刻挂载」都必须留痕,不得存在第二次静默回退。

回归锁都在 host-half.test.mjs:U9(先 apply、之后 provider 才 active 的时序用例)、U11(settings 彻底缺失时全功能可用且有且仅有一行 warn)、F1(provider 已 active 但 register() 恒抛错时,多次 get/update 后该窗口仍恰一行 warn);另有三组生命周期锁:provider 晚挂 → dispose 其真实 cordis fiber → 再回归(释放行、未挂载写走内存、重挂后读写都跟着新命名空间);门随窗口复位(窗口 2 的拒绝自己留一行,窗口内的惰性重试仍只算一行);事后链三态与 refused / no legacy data 的取值区分。

八、兼容性与字符串安全

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) 上无孤立代理项。

九、安装

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

依赖通过 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 = "\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 的验证里被实测确认过)。

方式二: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/cordis 都由 shell 提供,因此声明为 peerDependencies;只有与 shell 无身份耦合的纯库 schemastery 留在 dependencies。写成 dependencies 会在全新安装时拉进第二份同一个包(版本还可能落后于 shell)。

版本区间写成 ^0.1.0-rc.6 || ^0.1.5-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,甚至触发自动安装第二份。

依赖的宿主服务

| 服务 | 用途 | 缺失时 |
| --- | --- | --- |
| session-reference | 深链解析 | 必需(在 inject 数组里) |
| user-questions | 批准门 / 确认对话框 | 逐目标 fail-closed |
| session-query | 列表 / 导出 | 必需(在 inject 数组里) |
| agents | 投递 / 活性 | 必需(在 inject 数组里) |
| goals | 活性行的 goal 状态 | 降级:显示 goal=? |
| settings | 策略持久化 | 晚挂取用,降级为进程内记忆 + 一行 warn |
| webServer | 导出下载路由 | 晚挂取用,降级为「无路由,工具照常」+ 一行 warn |

DSH 默认装配均有。

十、测试

npm test                    # host 564 项 + client 138 项(合计 702 项)
node host-half.test.mjs     # 宿主半边,stub 风格(真 cordis Context)
node client-half.test.mjs   # 浏览器半边

断言总数由两个套件各自在结尾打印(assertion total: 564 (failed: 0) / assertion total: 138 (failed: 0)),文档里的计数即取自这两行——改测试后请同步本行与 CHANGELOG.md。

覆盖地图(按能力划分):

| 领域 | 覆盖要点 |
| --- | --- |
| 上游深链 | 9 例解析用例 + 深链快照注入 |
| 工具面 | 注册 / 列表 / 导出 / 发送 / 配对全流程,含拒绝、取消、自发送、死目标守卫 |
| 活性信号 | verdict 五态判定表 + 两个阈值边界、goals 缺失降级、读数时效戳 |
| surface 读取窗口 | 只读前 12 行且调用次数恰为 12、12 次读取并行在飞、第 13 行起降级、窗口内一行不可读只降级该行 |
| 看门狗 | 注册校验全表、四态巡逻策略、tick source 三成员与正文常量化、去抖、TTL 自清、观察者 dead 分支、dispose 清理定时器 |
| roster / 黑板 | 写权限三态与现任比对、upsert-team 幂等与 workspace 捕获、set-role 版本史与「不迁移 pairs」、retire 的置空/版本史/两条清理对话框分支、镜像一致性与失败降级、团队名与 file 白名单、decisions seq 与行格式与 500 字符上限、discipline baseHash 乐观锁两路、末 20 条窗口 |
| 广播 fan-out | 寻址解析与通配仅协调者、逐目标独立过门与 fail-closed、≤8 上限与整次拒绝(表达式数;卡内行数另受 §10.1.2 的 24 行上限约束)、去重、no-holder、单目标/广播互斥 |
| 信封 banner | 枚举校验全表、ref 按码点截断并注明、首行格式与部分键、source 仍三成员、fan-out 共享 meta |
| busy 预判 | 运行中分钟数 / 时间戳不可读回退 / 空闲原文案 / fan-out 逐目标 |
| U13 发送方回执(§10.1.2) | presentationMeta 已声明且仍走 textOutput 文案(模型可见文本零改动);单目标与 fan-out 两条路径的 kind/v/at/senderSessionId/targets/summary/fanout;正文 2000/2001 边界、头 1500 + 3 码点标记 + 尾 400、chars 记原始码点数、astral 切点无半截代理项、继承来的孤立代理项被修复;信封「给了才有」(含 meta:{} 不算);no-holder 的 sessionId:null 与 expr;去重计数;行数上限 24(一个 team:/* 合法展开出 30 行 → 卡内恰 24 行 + targetsTruncated:{shown:24,total:30} + 保留行是报告的前 24 行 + summary 仍 30 + 文本报告仍 30 行 + 30 个目标都真收到;对照:恰 24 行不截断且卡与改动前逐键一致);busy 三态(有分钟 / 读不到 / 空闲);投递阶段之前的拒绝只投影 {}(降级);审计 F2:sessionId / expr / meta.ref 三条路径分别污染 \uD800 后 JSON.stringify(card) 无孤立代理项(修复前 2 红、修复后全绿),对照组是同一次调用的模型可见文本本已干净;审计 B1:meta.ref 被截断时提示只进文本报告,卡内行仍是该目标的投递句(报告首行)且不含该提示,被截断的信封照常上卡 |
| U14 发送方工具行(§10.1.1 A) | 槽位 key 逐字 team_link_send(近形键不占该行);有回执 → A 面(极简标签「工具名 + 目标数」+ 逐目标行「目标(expr 或短 id)+ outcome + detail + busy」),且不含标题/时间/正文/汇总/信封;空目标表仍出标签(0 个目标);无回执(在飞 / 无 meta / 形状不认识 / 别的工具的 meta / 抛异常的 getter)→ 纯文本行并显示模型可见文案;12 种坏形状都不成卡且不抛错;targets 超过 SEND_CARD_ROW_LIMIT(24)的回执照常成卡,但 A 面行数封顶 24 并在卡上标注「已截断——仅显示前 24 行」,标签的总数仍是真的(round-1 🔵 #2;对照:恰 24 行全画且无标注、普通 3 目标卡不受影响);该渲染期判据的触发条件是回执自身超过 24 行,而宿主侧自 §10.1.2 修正轮起就在制卡时裁到 24,所以它现在只在异构实现或手改日志的 meta 上生效;宿主自产的 24 行卡带 targetsTruncated 字段,A 面读它(2026-09-19 收尾轮补的跨轮读路径)——24 行 + {shown:24,total:30} 的宿主形回执照常出标注且标签显示真值 30,而标签永远显示的是真值、不是画出的行数;zh/en 字典键集一致 |
| U15 顶层节点(§10.1.3 D) | 视图与接收方 key:"context" 同槽不同键并存;definition 只认既有 tool/call(名字逐字)与带本插件回执的 tool/result,其余事件类型一律不认;顶层节点产出(key/kind/id/target/anchorSeq/location/visibility/data);D 面(标题 + 发送方/时间 + 信封 + 正文 + 截断标注 + 汇总计数)且无逐目标行、无目标身份;窗口截断回退(tool/call 不在窗口仍出节点、别的工具的 meta 不出);无回执 / 在飞 / 形状坏 → 不渲染;审计 F1:两面可见文本取并集后任一语句恰好出现一次(任一面把另一面的块搬回来即红);审计 F3:模块级 inject 只有 slots/sessions/locale 三项,uiConversation 走 ctx.inject 动态注入——缺服务 / callback 从不触发 / ctx 无 inject 三种坏境下 apply() 都不抛、其余四条注册照常落地,只丢顶层卡;审计 B3:四条槽位注册(header 按钮条 + 三条 §10.1)各自加护栏,任一条 slots.register(或 slots.inject)抛错都只丢那一行、其余照常,且不牵连 definition——含 header 按钮条(round-1 🔵 #3:它跑在四条最前,未过护栏时一条拒绝会带走其后全部注册) |
| 换届 M4 | 令牌绑定与 TTL、rotationBackup 快照、速率限制、冻结清单、多选对话框逐项勾选、域限定迁移、对称撤销、落定与版本史、令牌掩码、四种拒绝、到期清扫与取消/回退、provisional 可见面、幂等重放、内部广播被屏蔽拦截、goals.resume 零调用红线 |
| §9 收尾修复 | U9 settings 时序回归锁(先 apply 后 active)、U10 创建即认领与不可劫持、U11 降级红线与「有且仅有一行」warn、F1 两条到达路径共用一次性门 |
| 字符串安全 | emoji 走遍 0..120 每一个切割偏移(其中恰好一个偏移在旧代码上留下半截 emoji)、生产边界、预污染源、导出切点、两处批准提问、投递 banner、深链快照注入、poisoned targetId 回显、回执卡的全部字符串成员(正文 + sessionId / expr / detail + senderSessionId + 信封 ref,按 JSON.stringify(card) 判定) |

两组容易复发的回归锁,值得单独点名:

- host-half.test.mjs 里的 AUDITED_SOURCE_KINDS 断言是迁移契约的回归锁:它按 dsh-session-format-v2-to-v3 的白名单与「恰好三成员」规则检查投递出去的 source,改坏了会立刻红;
- client-half.test.mjs 锁定「上游相邻代理消息不得被误判成本插件卡片」这条边界。
变异验证的证据文化:本仓库的修复都要求给出「修复前必红、修复后全绿」的两次实测输出——例如 0.3.7 收尾修复轮:把 lib 的修复逐条回退后 506 (failed: 4);把 createPolicyStore 换回真正的修复前形状则 506 (failed: 16)。没有这个证据的修复不算完成。

§10.1 A/D 轮(当次实测,逐条单点变异、改完全量回退后复跑基线 543 (failed: 0) / 104 (failed: 0)):宿主——去掉 sendCardMessage 的截断 → 543 (failed: 5);去掉结构化 busy → 543 (failed: 3);把 presentationMeta 改成恒返 {} → 套件当场崩(exit 1:客户端级断言读不到卡);客户端——把 A 的槽位 key 改成近形 team-link-send → 104 (failed: 3);不读回执(回退恒赢)→ 104 (failed: 12);去掉窗口截断回退 → 104 (failed: 2);让 match 认领每个 tool/result → 104 (failed: 1);把 D 的内层降级护栏改成 rethrow → 104 (failed: 1)。

差异审计分歧修复轮(当次实测,基线 552 (failed: 0) / 121 (failed: 0),单点变异跑完即逐条回退再复跑基线):宿主——只把 targets[].sessionId 的修复回退 → 552 (failed: 1)(F2 的 literal id 那条);只把 targets[].expr 的修复回退 → 552 (failed: 1)(F2 的 no-holder 那条,也就是审计插桩复现的两条路径);把信封 sendCardEnvelope 回退成直传 request.meta → 552 (failed: 1)(meta.ref 那条);两条同时回退(该轮加 B1 断言之前的基线)→ 549 (failed: 2);客户端——把 A 面退回修复前的形状(head/body/summary/foot 也渲染)→ 121 (failed: 4),其中并集断言当场打印出被重复的 head / 正文 / 汇总三句。

评审 round-1 🔵 收尾轮(当次实测,逐条先插断言跑红、修完复跑基线):基线 552 (failed: 0) / 121 (failed: 0);🔵 #3(header 按钮条并入 guardedSlot)——只插断言未修 → 123 (failed: 2)(「其余三条照常落地」与「definition 不受牵连」两条同时红),修完 → 123 (failed: 0);🔵 #2(A 面行数渲染期封顶)——把 SEND_CARD_ROW_LIMIT 退回修复前的无界形状(Infinity,即原来的 card.targets.map)→ 130 (failed: 2)(「行数有界」与「已截断标注」两条红,对照条仍绿),还原 → 130 (failed: 0);🔵 #1 是纯注释修正(lib/index.js 文件头的空闲投递语义由 inject 改为 followup),无断言可变异,其事实由同一文件的 if (running) target.steer(message); else target.followup(message); 与 README 的 followup 语义互为对照。

宿主侧 targets 行数界轮(§10.1.2 2026-09-19 修正的收口)(当次实测:先把 12 条新断言插进无界的宿主形状跑一次留证,再补上限复跑):插入后未修 → 564 (failed: 3)——「卡内恰 24 行」「卡自身带 targetsTruncated 标注」「标注紧跟 targets」三条同时红,而对照三条(「恰 24 行不截断」「≤24 行卡不多一个键」「≤24 行的计数与报告行数仍是 24」)与「summary 仍为全量」「文本报告仍 30 行」「30 个目标都真收到」当场全绿;补上 SEND_CARD_ROW_LIMIT = 24 的裁剪与标注后 → 564 (failed: 0),客户端 130 (failed: 0) 零改动零回归。红相里「保留行是报告的前 24 行」「卡仍是无损 JSON」两条本来就是绿的:前者在无界形状下对全部 30 行逐一比对了报告里的同一身份(有界后同一条断言只覆盖前 24 行,仍是同一条比较),后者与行数无关。

跨轮交互缺口收尾轮(客户端读宿主的 targetsTruncated,§10.1.2 / U14)(当次实测:先插 8 条断言、lib/client.js 一字未改跑红留证,再补客户端读路径复跑):红相 → 138 (failed: 3)——「A 面出截断标注(占位符 24)」「标签显示真值总数 30」「两类超限都能在界面上看出来」三条同时红(宿主自产的 24 行卡只画出 24 行,既不出标注、标签又把已画行数 24 当成了总数),而对照五条当场全绿(「宿主形回执照常成卡」「画出的恰是宿主保留的 24 行」「形状不认的标注被丢弃且不凭空出标注」「标注里的假 shown 不得进句子(句子报实际画出的 24)」「无标记且 ≤24 行的卡无标注且标签是自身行数」);补上 readTargetsTruncated、sendCardTargetTotal 与 A 面按标注出标注后 → 138 (failed: 0),宿主 564 (failed: 0) 零改动零回归(本轮未触碰 lib/index.js,宿主侧行为零改动)。

设计文档索引

| 文档 | 内容 |
| --- | --- |
| docs/team-upgrade-design-2026-09-17.md | 实施级设计(v1.4):M1–M5 机制、伪代码与 schema、安全边界与红线、验收标准(U1–U11 + 集成演练)、§9 收尾修复设计、会诊 #27 与清单闭合台账 |
| docs/collab-enhancements-design-2026-09-19.md | 协作增强设计:§10 ① 发送方可见性 A+D(已实施,U13–U15 见上)/ ② /team_session 自动建队(未实施);§11 自动换届交接(未实施)。会诊 #37 纪要见 docs/consult-minutes/2026-09-19-consult-37-minutes.md |
| docs/team-upgrade-research-2026-09-17.md | 调研:一次 16+ 小时真实多会话联调的复盘,与升级提案(其 §5 已被设计取代,以设计文档为准) |
| docs/consult-minutes/ | 多模型会诊纪要(含裁定层:逐条采纳/不采纳与理由、分歧父侧裁定、教训、不可验清单) |

Changelog

完整变更史见 CHANGELOG.md。最近一次:

- 0.3.7(当前版本)— 修两个在真实部署中实测到的功能性阻塞:settings 持久化静默失效(团队状态一直在进程内存里,从未落盘)与建队引导死锁(团队建了却永远写不进首任协调者)。前者是本次最贵的教训:它静默了整整一天,还让「改名迁移已完成」这个错误结论进了交付报告。

Credits

Fork 自 PwnKY/dsh-session-link——深链复制、/s/ 打开器、深链上下文注入均保留自上游,感谢上游工作。

License

MIT

上游仓库有新提交时邮件通知你(每天最多一封,无更新不打扰),随时一键退订。

同作者(shenhuanageshei)的其他插件

💬 加入 DPharness 群聊

插件用法、部署报错、新插件第一时间同步——群里问,比一个人翻文档快。

点击加入 QQ 群
DPharness 群聊二维码,手机 QQ 扫码进群
扫码进群