中文EN

dsh-peer-sessions

identitynpmMIT

平级对话之间的通道:经用户授权、对称,授权跨重启保留;忙时多条消息合并成一批按发送时间排序投递。

下载量 / Downloads
664

安装

dsh plugin --profile web add dsh-peer-sessions

沙箱测试

这个插件还没测过。提交测试 →

README

dsh-peer-sessions

为 DeepSeek Harness 提供同层级会话之间的、
由用户授权的对称通道——两个你自己指挥的会话互相对话。没有上级,也没有下级。

中文 · [English](README.md)


这是什么

两个长期会话——比如 /srv/orders-api 和 /srv/web-client——都归你,

谁也不比谁高一级。一个改了 API 契约就通知另一个;另一个改完就回一声。

这不是 subagent。分界线是:

**一方结束就意味着另一方也该停 —— 那是 subagent。
两者可以各自独立地活下去 —— 那才是 peer。**

现有的 send_message / list_agents 建立在"父拥有子"(isOwnedBy)之上,

而 peer 通道没有所有权关系,所以不能复用它。

那条硬规则

**agent 眼里的对话列表,必须是人在侧栏能看到的列表的子集。

一个 agent 绝不能对着一个人类看不见的对话说话。**

可寻址集合直接由侧栏自己的可见性规则推导

(dsh-client-ui-workspace 里的 sessionVisible)。下面是那两处宿主读取——

服务名与返回形状都请注意,README 初稿两处都写错了:

// 规则的完整形态(含"每个隐藏会话为什么被隐藏")在 lib/addressable.js 的
// `classify`——那个文件是权威,这里只是示意。
const archived = new Set(ctx.workspaceRegistry.archivedSessionIds)
const { items } = await ctx.sessionController.list({}, signal)   // 是 { items },不是数组
const addressable = items.filter(s =>
      s.origin !== 'subagent'
   && !archived.has(s.sessionId)
   && !s.blank)

归档的、空白占位的、子代理的会话被排除。每次投递都会重新读取这个列表,

所以归档一个对端会立刻切断可达性——不需要监听器,也没有缓存。

设计

完整冻结规格见 [docs/design.md](docs/design.md) —— 它规定"应该做成什么样、为什么",是唯一权威来源。

维护

[MAINTENANCE.md](MAINTENANCE.md) 写给下一个接手的人,包括没有任何历史上下文的新对话:怎么接线、四条硬不变量,以及十七个已经真实花过时间的坑——会 re-apply 却不重新读模块的加载器、不能软链的技能、必须是软链的 node_modules、以及和直觉不符的投影形状。

动手前先读它。

接口

你手打的命令

命令 · 作用

/peers · 列出通道:对端、授权档位、剩余额度、对端可见性

/peer connect <标题> [once\ · session] · 建立通道;档位选择走原生选项卡片

/peer revoke <标题> · 断开通道

/peer progress <标题> · 查看对端进度

模型调用的工具

工具 · 作用

peer_send · 发消息;无授权时弹出授权卡片

peer_inbox · 读收件箱:每条一行摘要,或按 id 取回某条的完整投递正文

peer_progress · 读对端投影 + 摘要

peer_list · 查看通道与权限

零客户端代码。 命令结果走现有聊天 UI,授权卡片走现有 user-questions UI,

消息来源标注走现有的 form: 'relay' 渲染。本包不声明 dsh.client。

环路防护

两个会话互相回复可以无限持续,所以有三道上限加一个超时通知,全部在投递之前判定——被拒绝的投递不消耗额度、也不推进链条。下表每个数字都是配置项(见[配置](#配置)),这里给的是默认值。

防护 · 上限 · 说明

每对速率 · 30 条 / 60 秒 · 按通道计的固定窗口

每对总量 · 200 条 · 一条通道一生的投递总量(once 档为 1)

跳数 · 3 跳 · 一条因果链跨会话被接力转发的深度;两个会话正常来回不受此限

请求超时 · replyWithin · 到点通知发起方一次,绝不自动重发

request 的 replyWithin(如 "15m"、"4h")是真正的期限:到点后发起方下一次调用工具时会看到一条 Overdue 提示;收到请求的一侧在列表里看到 [timed out · …],取回正文时抬头多一行 deadline: OVERDUE。

怎么"等",以及为什么不要等

投递在两个方向上都遵循"空闲才投递",第二个方向才是最容易踩的:

  • 你发出的消息排在对端当前回合之后——绝不打断它;
  • 对端的回信排在你自己当前回合之后,会新开一个回合,而不是打断你正在跑的这一轮。阻塞调用是原子的,所以一条长命令执行期间到达的回信只能排在它后面。

所以 agent 不要为等对端而 sleep、轮询或挂 wait_agent。要么结束回合、让回信把你唤醒;要么必须在本回合继续干活时主动拉取 peer_inbox——它读的是插件自己的收件箱,已经送达的回复立刻就能读到全文,尽管它的中继消息要到下一回合才进上下文。

回合进行中,入站的 peer 消息会出现在输入框上方的队列条里,并带着完整来源抬头——那一栏里不属于你的文字会写明是谁说的。每一项都带 编辑 / 删除 / 提升为 steer 三个控件,Cmd/Ctrl+Enter 则可把全部排队消息插话发送。这就是长步骤卡住回信时,人这边的逃生门。

积压会合并,而且每条都带发送时间

长任务期间,同一对话可能连着发来好几条。宿主每个回合只取一条排队消息,所以"三条 = 三个回合"——第一个回合读到的状态,往往已经被后面两条推翻了,而它要干完活才会看到后面两条。两处改动针对这件事:

  • 每条 peer 消息的抬头里都有 sent: 2026-10-17T20:01:02+08:00(本地时间带偏移,可以直接和每轮运行环境里的时间比)。看一眼就知道这条是刚发的还是排队 8 分钟的。
  • 忙时的新消息会合并进"还没被读到"的那条:对端读过之前,后到的一条被折叠进同一条排队消息里,于是对端一个回合读到一批,带顺序、带每条的时间、并明确写着"后面的可能修正或推翻前面的"。队列条里始终只有一条,不会变成 N 个回合。
  • 一条都不会丢:如果那条排队消息刚好被读了(replace 返回 false),新消息照旧成为自己的回合;超过 mergeMaxChars 上限时,多出来的部分也照旧单独成条。
  • silent 投递不参与合并(它走的是"下一步"上下文注入,替换在途内容风险不同)。
  • 收件箱仍是每条一个条目:合并只发生在投递这一侧,peer_inbox(id) 仍按原 id 取回各自正文。

配置

阈值由你在插件的组合行里调:

- id: dsh-peer-sessions
  name: dsh-peer-sessions
  config:
    hopLimit: 5
    rateLimit: 60
    rateWindowMs: 60000
    channelBudget: 500
    inboxLimit: 100
    requestMemory: 1000
    pendingGraceMs: 86400000
    hopMemoryMs: 900000
    maxCandidates: 8
    recentTurns: 3
    previewMaxChars: 200

字段 · 默认 · 管什么

hopLimit · 3 · 一条链跨会话被接力多少跳之后拒绝

rateLimit / rateWindowMs · 30 / 60000 · 一对会话在一个固定窗口内能投递多少条

channelBudget · 200 · 一条通道一生的投递总量

hopMemoryMs · 900000 · 一条入站消息在多长时间内仍算"正在回答的那条"

inboxLimit · 50 · 每个会话保留的收件箱条目数

requestMemory · 500 · 为回复方向校验记住多少个请求发起方

pendingGraceMs · 86400000 · 未回答的请求继续被报告多久

maxCandidates · 8 · 标题歧义时最多列几个候选会话

recentTurns · 3 · peer_progress 摘要末尾几轮

previewMaxChars · 200 · peer_progress 单行预览的上限

mergeWhileBusy · true · 对端还没读时,把新消息合并进排队的那条

mergeMaxChars · 12000 · 一条合并消息的正文上限,超过就分开投递

改之前有两件事值得知道:

  • 一行的 config 是整体替换,不是深合并。 只写一个字段没问题(其余由 schema 声明的默认值补齐,有测试钉住);但多层覆盖同一行时,靠后的那个 config 对象整块胜出。若你从两处设置,靠后的那处要列全你想保留的字段。
  • 每个字段都有默认值。 少一个默认值,省略 config 就会变成校验失败;而校验失败的插件在启动时只是一条警告——profile 照常启动,插件却静默消失。默认值就是上表的值,所以完全省略 config 永远安全。

数据与边界

授权写进会话日志,其余一切都在进程内存里。

  • 授权是持久的(v1.3.0 起):通道建立时,一条插件记录写进批准它的那个会话的日志——ignorable: true、类型 plugin:dsh-peer-sessions/channel、不进模型可见面、不进已发布的事件词汇表。于是重启 dsh、插件重载、停用再启用之后,通道仍在,直到你用 /peer revoke 撤销。撤销本身也是持久的:墓碑写给两端所有活着的会话,批准方当时是冷的话,它下次加载时跨日志核对(sessionController.inspect(),不唤醒)。
  • 收件箱、待答期限、hop 记忆、速率窗口仍只在进程内存里:它们描述的是"在途的投递",重启结束在途的这一半。peer_inbox(id) 那句"正文仍可取回"的边界因此是宿主进程的生命周期,不是日志的生命周期。
  • 授权记在批准方,不记在对端:对端的人从没见过那张卡片。所以重启后,通道要等批准方先把它加载回来;对端先发话时会重新问它自己的人要卡片——它的人确实没批准过任何东西。
  • 额度也是持久的:花掉的投递、once 档的"已花掉这一次往来"都写进日志,重启不会重发额度。
  • fork 会带着授权记录(日志被复制),所以 fork 出来的同一段历史可以继续用那条通道——那仍是同一个人的同一段对话。
  • 为什么能持久化:0.2.1 的 appendPluginRecord 写的是词汇表之外的 ignorable 事件。早先尝试的"自定义 type"被否决是对的——那会让会话打不开;插件记录绕开了这个代价。详见 docs/design.md §6 与 MAINTENANCE.md §二。
  • 需要 dsh ≥ 0.2.1:更老的宿主没有 appendPluginRecord,插件照常工作,只是授权退回"进程内"的旧寿命,并在日志里留一条警告——不会因为版本不够而拒绝加载。

卸载

./uninstall.sh

它按顺序撤销安装的四步:

步骤 · 移除什么

1 · 已部署的技能目录 ~/.dsh/skills/peer-session/

2 · profile cordis.patch.yml 里手写的 insert 行(先备份)

3 · profile 依赖(dsh plugin remove dsh-peer-sessions)

4 · 本包的 node_modules 软链

之后必须重启 profile——进程不重启,插件就一直还在。走路线 A(插件管理器)安装的,请在管理器里卸载;只对你实际用的那条路线执行,事后核对:

grep -c dsh-peer-sessions ~/.dsh/profiles/web/cordis.patch.yml   # → 0

安装

两条路线,只能选一条。 它们

…(truncated)

以上内容来自 npm 上的 README,我们只做格式化,不改内容。

版本

npm 上共发布:2,最后更新 2026-10-22

链接

GitHub · Homepage · npm

同类插件