dsh-agent-message 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -12,8 +12,8 @@ English | [中文](./README.md)
12
12
 
13
13
  In DeepSeek Harness, a single process hosts multiple Agent sessions at once. This plugin equips each session with three tools so they can "message" each other:
14
14
 
15
- - Before sending, first **list every sendable session** (all non-archived ones are listed, including offline ones that haven't been reopened), and find the target by its title;
16
- - Once found, **deliver the message to the target session** — ordinary messages always enter a new independent turn; if the target is offline (not loaded since the last process restart), the plugin resumes it through Harness's public API, delivers the message, and releases the runtime after processing;
15
+ - Before sending, first **list every sendable independent session** (non-archived, excluding actual subagents, including offline sessions that have not been reopened), and find the target by its title;
16
+ - Once found, **deliver the message to the target session** — ordinary messages always enter a new independent turn; if the target is offline (not loaded since the last process restart), the plugin resumes it through Harness's public API, delivers the message, and keeps the handle loaded for later communication until plugin teardown;
17
17
  - When needed, **query the delivery status of a message on demand** (queued / claimed / discarded / unknown), with the target runtime status reported separately for supervision scenarios.
18
18
 
19
19
  Typical scenarios: an orchestrator Agent dispatching work to a developer Agent, two Agents collaborating in a relay, a main session sending instructions to a test session, or a supervisor Agent watching over several workers.
@@ -33,7 +33,7 @@ Typical scenarios: an orchestrator Agent dispatching work to a developer Agent,
33
33
 
34
34
  ![Clickable sender header example](./docs/assets/message-header-navigation.jpg)
35
35
 
36
- The image shows navigation on a historical `user` bubble. Current relay messages use the same visible-card and sender-navigation experience while retaining plugin `relay` provenance instead of impersonating human input. The full session id remains in typed source metadata and in a Host-generated model-visible protocol header, so the receiving Agent never has to guess the sender.
36
+ Current relay messages are displayed as visible Agent message cards; clicking the header opens the sender session. The persisted source remains plugin `relay` provenance rather than impersonating human input. The full session id remains in typed source metadata and in a Host-generated model-visible protocol header, so the receiving Agent never has to guess the sender.
37
37
 
38
38
  ### Delivery modes (the `mode` parameter of `send_agent_message`)
39
39
 
@@ -78,8 +78,8 @@ The plugin ships a `cordis.patch.yml` (pointed to by `dsh.bundle.patch` in `pack
78
78
  ## Usage
79
79
 
80
80
  1. Type `@` at the beginning of session A's composer and choose the target from the native candidate menu; each candidate shows its title and `Running`/`Idle` activity;
81
- 2. `@` only tells A where the relevant session is; it does not mean send. A calls `send_agent_message` when either the current request or an orchestration responsibility already granted by the user requires cross-session communication, and makes that routing decision silently. For example, `@B tell it to stop after opening the draft PR` sends, while `@B analyze its latest conversation result` only reads B on demand without explaining the internal routing decision;
82
- 3. For an explicit forwarding request, A only delivers and reports the result. It must not execute the forwarded task itself or ask B for an extra acknowledgement. B sends a business result to `senderSessionId` only when the body explicitly asks for one;
81
+ 2. `@` only tells A where the relevant session is; it does not mean send. A calls `send_agent_message` only when the current request or an orchestration responsibility already granted by the user requires cross-session communication. For example, `@B tell it to stop after opening the draft PR` sends, while `@B analyze its latest conversation result` must not send a message to B;
82
+ 3. For an explicit forwarding request, A only delivers and reports whether the message was accepted or failed. It must not execute the forwarded task itself or ask B for an extra acknowledgement. B sends a message to `senderSessionId` only when the body explicitly asks it to return business content;
83
83
  4. You can still ask the Agent to call `list_peer_agents` and send directly with a full session id;
84
84
  5. Session B receives a native `UserMessage` with a typed relay source plus a minimal Host-generated source header on the first body line, so B does not have to guess the sender. The Client presents it as a visible Agent message card whose header opens the sender session;
85
85
  6. (Supervision) Say "check the status of my messages to `<session id>`" — it calls `check_delivery`.
@@ -99,14 +99,16 @@ Delivery paths of `send_agent_message`:
99
99
 
100
100
  Session enumeration, batched titles, and offline log reads use Harness's `sessionQuery.listSessions()`, `readTitleSnapshots()`, and `readSession()` respectively. `SessionId` is the only address; `parentSession` records fork lineage only, and only `origin: subagent` identifies an actual subagent. The plugin does not scan `sessionPersistence` directly to rebuild a parallel session directory.
101
101
 
102
- After `send_agent_message` submits the native message to the target Inbox, it immediately returns `accepted` with the native `messageId`. `check_delivery` then derives `pending` (still queued), `claimed` (claimed by a turn), `discarded` (cancelled), or `unknown` from Inbox events. `claimed` is transport evidence only: it does not prove that the message was read, answered, or completed. Pre-admission failure remains a Harness tool error and writes nothing to the target Inbox. Runtime state is reported separately as `targetRuntimeStatus`, so unrelated Agent activity never changes the message state. A known `messageId` remains queryable from the target Inbox log after a process restart.
102
+ After `send_agent_message` submits the native message to the target Inbox, it immediately returns `accepted` with the native `messageId`; the model receives only a terse “delivered” projection while the complete result stays in tool presentation metadata. `check_delivery` then derives `pending` (still queued), `claimed` (claimed by a turn), `discarded` (cancelled), or `unknown` from Inbox events on demand. `claimed` is transport evidence only: it does not prove that the message was read, answered, or completed. Pre-admission failure remains a Harness tool error and writes nothing to the target Inbox. Runtime state is reported separately as `targetRuntimeStatus`, so unrelated Agent activity never changes the message state. A known `messageId` remains queryable from the target Inbox log after a process restart.
103
103
 
104
104
  Every cross-session message is created by Harness `createUserMessage()`, and `UserMessage.id` is its only message identity. Its source always uses `kind: dsh-agent-message` and `form: relay`, plus the protocol version, sender/target Session ids, and display title. Because current Harness model requests do not expand custom source fields, the Host also writes a minimal `<dsh-agent-message>` header containing only `senderSessionId` on the first body line. The typed source is the durable/UI truth; the header is only the model-visible projection needed for reply addressing. The plugin registers no global system prompt; send admission lives only in the `send_agent_message` tool contract. The Client only projects relay as a visible Agent message card and never rewrites an Agent message as human `user` provenance.
105
105
 
106
- The current release has no separate result protocol: relay only means “a message addressed by another session”; it neither requires nor forbids a reply. When the body explicitly requests a business result, the receiving Agent may send it to `senderSessionId` with the same tool. Otherwise it must not send a transport acknowledgement or a bare “received.” A correlated Result protocol should be added only when machine-verifiable request/result linkage is required.
106
+ Relay only means “a message addressed by another session”; it neither requires nor forbids a reply. When the body explicitly requests business content in return, the receiving Agent may send a message to `senderSessionId` with the same tool. Otherwise it must not send a transport acknowledgement or a bare “received.” The plugin does not automatically correlate requests and replies or forward ordinary Agent answers.
107
107
 
108
108
  The composer-side `@` session locator reuses Harness's native `inputTriggers` command marker. The visible selected title is capped at 40 Unicode characters with an ellipsis; submission replaces it with the full stable `@session-...` id for the current Agent. The sent bubble still projects that id with a chat icon and the live session title, so renaming a session does not change the locator target.
109
109
 
110
+ See [`docs/architecture-v2.md`](./docs/architecture-v2.md) for the current architecture contract.
111
+
110
112
  ## Directory structure
111
113
 
112
114
  ```
@@ -116,18 +118,16 @@ dsh-agent-message/
116
118
  │ └── client.js # client half: @session references, session navigation and copy-session-id button
117
119
  ├── cordis.patch.yml # self-registration patch (pointed to by dsh.bundle.patch)
118
120
  ├── package.json # DSH plugin manifest (dsh.bundle / dsh.client / dshx.contributes)
119
- ├── docs/ # design notes and README example screenshot
121
+ ├── docs/ # current architecture and README example screenshot
120
122
  ├── README.md # Chinese documentation
121
123
  └── README.en.md # English documentation
122
124
  ```
123
125
 
124
- ## In Development
125
-
126
- - **Cross-process communication:** Allow Agent sessions running in different DSH processes to exchange messages.
127
-
128
126
  ## Limitations
129
127
 
130
128
  - The target session must be **non-archived** and present in local persistence; archived sessions are always rejected.
129
+ - These tools are only for communication between independent sessions; actual subagents are neither listed as targets nor allowed to call them.
130
+ - One Session pair, regardless of direction, may receive at most 10 deliveries in a rolling 60-second window. The 11th is rejected before the target Inbox is changed. This window belongs to the current Harness process and resets on restart.
131
131
  - Automatically resuming an offline session uses the **default model** (it does not inherit a model manually selected earlier in that session); if resume fails, the message is not written to the target Inbox.
132
132
  - Bulk receipt queries without `messageId` rely on in-memory bookkeeping and cover only the most recent 1000 sends in the current process (FIFO eviction). After a restart, a known `messageId` remains queryable, but the volatile `sentAt` and `mode` fields are no longer returned.
133
133
  - Cross-process / cross-machine communication is out of scope.
package/README.md CHANGED
@@ -12,8 +12,8 @@
12
12
 
13
13
  在 DeepSeek Harness 里,一个进程会同时挂着多个 Agent 会话。本插件给每个会话装上三个工具,让它们能互相"发消息":
14
14
 
15
- - 发消息前,先**列出所有可发送的会话**(未归档的都在列,含离线未打开的),按标题找到目标;
16
- - 找到后,**把消息投递到目标会话**——普通消息统一进入独立的新 turn;目标离线(进程重启后还没打开)时,插件通过 Harness 公开接口恢复会话、投递,并在处理结束后释放 runtime
15
+ - 发消息前,先**列出所有可发送的独立会话**(未归档、排除真实子代理,含离线未打开的),按标题找到目标;
16
+ - 找到后,**把消息投递到目标会话**——普通消息统一进入独立的新 turn;目标离线(进程重启后还没打开)时,插件通过 Harness 公开接口恢复会话、投递,并保持加载供后续通信,插件卸载时再释放 handle
17
17
  - 需要时,可以**按需查询**某条消息的送达状态(排队中/已认领/被丢弃/未知),并单独查看目标是否正在运行,供监督场景使用。
18
18
 
19
19
  典型场景:编排者 Agent 给开发 Agent 派活、两个 Agent 协作接力、主会话给测试会话发指令、监督者 Agent 盯梢多个 worker。
@@ -33,7 +33,7 @@
33
33
 
34
34
  ![可点击的发送者消息头示例](./docs/assets/message-header-navigation.jpg)
35
35
 
36
- 图中是历史 `user` 气泡的导航效果;当前 relay 消息使用相同的可见卡片和发送方跳转,但持久化来源仍是插件 `relay`,不会伪装成人类输入。完整会话 ID 同时保留在 typed source 和 Host 生成的模型可见协议头中,避免接收 Agent 猜测发送方。
36
+ 当前 relay 消息显示为可见的 Agent 消息卡片;点击消息头即可跳转到发送方会话。持久化来源仍是插件 `relay`,不会伪装成人类输入。完整会话 ID 同时保留在 typed source 和 Host 生成的模型可见协议头中,避免接收 Agent 猜测发送方。
37
37
 
38
38
  ### 投递模式(`send_agent_message` 的 `mode` 参数)
39
39
 
@@ -79,8 +79,8 @@ Agent 会用 bash 执行这条命令,装完自动挂载、所有会话立即
79
79
  ## 使用
80
80
 
81
81
  1. 在会话 A 的输入框开头键入 `@`,从原生候选菜单中选择目标会话;候选会显示标题和“运行中/空闲”;
82
- 2. `@` 只告诉 A 信息或操作的目标在哪里,不代表发送。当前请求或用户已授予的编排职责要求跨会话传递信息时,A 调用 `send_agent_message`并静默执行路由决策。例如 `@B 告诉他最后提交 PR draft 就停止` 会发送,`@B 帮我分析他最新的对话结果` 则只按需读取 B,不向用户解释内部路由判断;
83
- 3. 显式要求转告时,A 只负责投递和报告结果,不代为执行被转发的任务,也不要求 B 额外回复“收到”;如果正文明确要求 B 把业务结果返回 A,B 才向 `senderSessionId` 发送结果;
82
+ 2. `@` 只告诉 A 信息或操作的目标在哪里,不代表发送。当前请求或用户已授予的编排职责要求跨会话传递信息时,A 才调用 `send_agent_message`。例如 `@B 告诉他最后提交 PR draft 就停止` 会发送,`@B 帮我分析他最新的对话结果` 则不应向 B 发消息;
83
+ 3. 显式要求转告时,A 只负责投递并报告“已接受”或失败,不代为执行被转发的任务,也不要求 B 额外回复“收到”;如果正文明确要求 B 把业务内容返回 A,B 才向 `senderSessionId` 发送消息;
84
84
  4. 也可以让 Agent 调 `list_peer_agents`,再用完整会话 ID 直接发送;
85
85
  5. 会话 B 收到的是带 typed relay source 的原生 `UserMessage`;正文首行还有 Host 生成的最小来源协议,B 不需要猜测发送方;Client 将其显示为可见 Agent 消息卡片,并可从消息头打开发送方会话;
86
86
  6. (监督场景)说「查一下我发给 `<会话ID>` 的消息状态」——它会调 `check_delivery`。
@@ -100,14 +100,16 @@ Agent 会用 bash 执行这条命令,装完自动挂载、所有会话立即
100
100
 
101
101
  会话枚举、批量标题和离线日志读取分别使用 Harness 的 `sessionQuery.listSessions()`、`readTitleSnapshots()` 与 `readSession()`。`SessionId` 是唯一地址;`parentSession` 只记录分叉血缘,只有 `origin: subagent` 才会被识别为真实子代理。插件不直接扫描 `sessionPersistence` 重建另一份会话目录。
102
102
 
103
- `send_agent_message` 成功把原生消息提交给目标 Inbox 后立即返回 `accepted` 和该消息的原生 `messageId`;`check_delivery` 根据 Inbox 事件返回 `pending`(仍在排队)、`claimed`(已被某轮认领)、`discarded`(被取消)或 `unknown`。`claimed` 只是传输证据,不表示已读、回复或任务完成。接纳前失败由 Harness 工具错误表示,不写入目标 Inbox。目标是否正在运行通过独立的 `targetRuntimeStatus` 返回,不把 Agent 的整体运行状态误当成某条消息正在处理。指定 `messageId` 时可从目标现有 Inbox 日志恢复状态,因此进程重启后仍可查询。
103
+ `send_agent_message` 成功把原生消息提交给目标 Inbox 后立即返回 `accepted` 和该消息的原生 `messageId`;模型只接收简短的“已投递”,完整结果保留在工具呈现元数据中。`check_delivery` 根据 Inbox 事件按需返回 `pending`(仍在排队)、`claimed`(已被某轮认领)、`discarded`(被取消)或 `unknown`。`claimed` 只是传输证据,不表示已读、回复或任务完成。接纳前失败由 Harness 工具错误表示,不写入目标 Inbox。目标是否正在运行通过独立的 `targetRuntimeStatus` 返回,不把 Agent 的整体运行状态误当成某条消息正在处理。指定 `messageId` 时可从目标现有 Inbox 日志恢复状态,因此进程重启后仍可查询。
104
104
 
105
105
  所有跨会话消息都由 Harness `createUserMessage()` 创建,`UserMessage.id` 是唯一消息身份。`source.kind` 固定为 `dsh-agent-message`,`form` 固定为 `relay`,并携带协议版本、发送/目标 Session 和显示标题。由于当前 Harness 不会把自定义 source 字段展开给模型,Host 还会在正文首行写入只含 `senderSessionId` 的最小 `<dsh-agent-message>` 协议头;source 是持久化/UI 真相,协议头只是回复寻址所需的模型可见投影。插件不注册全局系统提示词,发送准入只存在于 `send_agent_message` 的工具合同中。Client 只把 relay 投影为可见的 Agent 消息卡片,不会反向把 Agent 消息伪装成人类 `user` 来源。
106
106
 
107
- 当前版本没有额外的结果协议:relay 只表达“另一会话发来的消息”,本身不等于必须回复或禁止回复。正文明确要求返回业务结果时,接收 Agent 可用同一工具向 `senderSessionId` 发送结果;没有明确要求时不回传 transport ack 或单纯的“收到”。需要机器可核验的请求/结果关联时,再引入独立 Result 协议。
107
+ relay 只表达“另一会话发来的消息”,本身不等于必须回复或禁止回复。正文明确要求返回业务内容时,接收 Agent 可用同一工具向 `senderSessionId` 发送消息;没有明确要求时不回传 transport ack 或单纯的“收到”。插件不自动关联请求与回复,也不自动转发 Agent 的普通回答。
108
108
 
109
109
  输入框的 `@` 会话定位复用 Harness 原生 `inputTriggers` 命令标记:选择后的可见标题最多 40 个 Unicode 字符,超出用省略号;提交给当前 Agent 时换成完整 `@session-...` 稳定 ID。发送后的气泡依然用聊天图标和实时会话标题投影该 ID,显示名称变化不会改变定位目标。
110
110
 
111
+ 完整的现役架构合同见 [`docs/architecture-v2.md`](./docs/architecture-v2.md)。
112
+
111
113
  ## 目录结构
112
114
 
113
115
  ```
@@ -117,18 +119,16 @@ dsh-agent-message/
117
119
  │ └── client.js # client 半区:@会话引用、会话导航与复制会话ID按钮
118
120
  ├── cordis.patch.yml # 自注册补丁(dsh.bundle.patch 指向它)
119
121
  ├── package.json # DSH 插件清单(dsh.bundle / dsh.client / dshx.contributes)
120
- ├── docs/ # 设计稿与 README 示例截图
122
+ ├── docs/ # 现役架构与 README 示例截图
121
123
  ├── README.md # 中文文档
122
124
  └── README.en.md # English documentation
123
125
  ```
124
126
 
125
- ## 正在开发
126
-
127
- - **跨进程通信**:让运行在不同 DSH 进程中的 Agent 会话也能互相收发消息。
128
-
129
127
  ## 限制
130
128
 
131
129
  - 目标会话必须**未归档**且存在于本机持久化里;归档会话一律拒绝发送。
130
+ - 工具只用于独立 Session 之间通信;真实子代理既不会出现在目标列表中,也不能作为调用方使用这些工具。
131
+ - 同一对 Session(不分发送方向)在滚动 60 秒内最多投递 10 条消息;第 11 条会在写入目标 Inbox 前被拒绝。该窗口只属于当前 Harness 进程,重启后清空。
132
132
  - 自动恢复离线会话时会使用**默认模型**(不继承它上次手动切换的模型选择);恢复失败时消息不会被写入目标 Inbox。
133
133
  - 不指定 `messageId` 的批量回执依赖内存记账,只覆盖本进程最近 1000 条发送记录(FIFO 淘汰);进程重启后仍可凭已知 `messageId` 查询,但不再返回易失的 `sentAt` 和 `mode`。
134
134
  - 跨进程/跨机器通信不在本插件范围内。
package/lib/client.js CHANGED
@@ -14,6 +14,7 @@ window.__ModuleLoader__.load({
14
14
  function apply(ctx) {
15
15
  const senderSelector = '[data-ref-chip="subagent"], [data-context-relay-sender]';
16
16
  const referenceSource = "agent-message-session";
17
+ const reactRootSelector = "[data-agent-msg-react-root]";
17
18
  const candidateRows = new WeakMap();
18
19
  const mountedRoots = new Map();
19
20
  const sessionLinks = new Set();
@@ -53,20 +54,26 @@ window.__ModuleLoader__.load({
53
54
  if (!root) {
54
55
  root = createRoot(host);
55
56
  mountedRoots.set(host, root);
57
+ host.setAttribute("data-agent-msg-react-root", "true");
56
58
  }
57
59
  root.render(content);
58
60
  }
59
61
 
60
62
  function cleanupRoots(node) {
61
- mountedRoots.forEach(function (root, host) {
62
- if (node === host || (node instanceof Element && node.contains(host))) {
63
- root.unmount();
64
- mountedRoots.delete(host);
65
- }
66
- });
67
- sessionLinks.forEach(function (element) {
68
- if (node === element || (node instanceof Element && node.contains(element))) sessionLinks.delete(element);
63
+ if (!(node instanceof Element)) return;
64
+ const hosts = node.matches(reactRootSelector)
65
+ ? [node].concat(Array.from(node.querySelectorAll(reactRootSelector)))
66
+ : Array.from(node.querySelectorAll(reactRootSelector));
67
+ hosts.forEach(function (host) {
68
+ const root = mountedRoots.get(host);
69
+ if (!root) return;
70
+ root.unmount();
71
+ mountedRoots.delete(host);
69
72
  });
73
+ const links = node.matches(".agent-msg-session-link")
74
+ ? [node].concat(Array.from(node.querySelectorAll(".agent-msg-session-link")))
75
+ : Array.from(node.querySelectorAll(".agent-msg-session-link"));
76
+ links.forEach(function (element) { sessionLinks.delete(element); });
70
77
  }
71
78
 
72
79
  function SessionActivity(props) {
@@ -84,7 +91,7 @@ window.__ModuleLoader__.load({
84
91
  function RelaySender(props) {
85
92
  return React.createElement("span", { className: "agent-msg-relay-sender-content" },
86
93
  React.createElement(IconQueueOutline14, { size: 14, className: "agent-msg-session-icon" }),
87
- React.createElement("span", null, "From Session · " + props.title + ":"));
94
+ React.createElement("span", null, uiText("来自会话 · ", "From Session · ") + props.title + ":"));
88
95
  }
89
96
 
90
97
  function enhanceReferenceHost(element, row) {
@@ -166,7 +173,7 @@ window.__ModuleLoader__.load({
166
173
 
167
174
  function titleFrom(text) {
168
175
  const line = String(text || "").split("\n", 1)[0].trim();
169
- const current = line.match(/^From (?:Session|Agent)(?: ·)? (.+?):(?: @session-[\w-]+)?$/);
176
+ const current = line.match(/^(?:From (?:Session|Agent)|来自会话)(?: ·)? (.+?):(?: @session-[\w-]+)?$/);
170
177
  const previous = line.match(/^来自 Agent · (.+)$/);
171
178
  const legacy = line.match(/^来自 Agent「(.+)」\s*[·::]?$/);
172
179
  return (current || previous || legacy)?.[1]?.trim() || "";
@@ -177,7 +184,6 @@ window.__ModuleLoader__.load({
177
184
  const label = element.previousElementSibling;
178
185
  const title = titleFrom(label?.textContent);
179
186
  if (title && label) {
180
- label.textContent = "";
181
187
  label.classList.add("agent-msg-sender-prefix");
182
188
  }
183
189
  return title;
@@ -197,13 +203,13 @@ window.__ModuleLoader__.load({
197
203
  element.dataset.agentMsgSessionId = link.sessionId;
198
204
  if (title) element.dataset.agentMsgSenderTitle = title;
199
205
  if (relay) renderInto(element, React.createElement(RelaySender, { title: title }));
200
- else element.textContent = "From Session · " + title + ":";
206
+ else element.textContent = uiText("来自会话 · ", "From Session · ") + title + ":";
201
207
  element.classList.add("agent-msg-session-link", "agent-msg-sender-link");
202
208
  sessionLinks.add(element);
203
209
  element.setAttribute("role", "link");
204
210
  element.setAttribute("tabindex", "0");
205
- element.setAttribute("title", "打开发送方会话");
206
- element.setAttribute("aria-label", "打开发送方会话:" + (title || link.sessionId));
211
+ element.setAttribute("title", uiText("打开发送方会话", "Open sender session"));
212
+ element.setAttribute("aria-label", uiText("打开发送方会话:", "Open sender session: ") + (title || link.sessionId));
207
213
  });
208
214
  }
209
215
 
@@ -363,14 +369,18 @@ window.__ModuleLoader__.load({
363
369
  type: "button",
364
370
  onClick: onClick,
365
371
  onMouseLeave: onMouseLeave,
366
- title: "复制会话 ID",
367
- "aria-label": "复制会话 ID",
372
+ title: uiText("复制会话 ID", "Copy session ID"),
373
+ "aria-label": uiText("复制会话 ID", "Copy session ID"),
368
374
  className: "agent-msg-copy-id"
369
- }, copyState === "copied" ? "已复制" : copyState === "failed" ? "复制失败" : "复制ID");
375
+ }, copyState === "copied"
376
+ ? uiText("已复制", "Copied")
377
+ : copyState === "failed"
378
+ ? uiText("复制失败", "Copy failed")
379
+ : uiText("复制ID", "Copy ID"));
370
380
  }
371
381
 
372
382
  ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register(
373
- { name: "conversation.session.header.actions", id: "copy-session-id", order: 30, label: "复制会话ID" },
383
+ { name: "conversation.session.header.actions", id: "copy-session-id", order: 30, label: uiText("复制会话ID", "Copy session ID") },
374
384
  (props) => React.createElement(CopyButton, { sessionId: props.sessionId })
375
385
  ));
376
386
 
package/lib/index.js CHANGED
@@ -10,9 +10,14 @@ export function apply(ctx) {
10
10
  const sent = new Map()
11
11
  /** 记账表 FIFO 上限:超过则淘汰最老记录,内存恒定。 */
12
12
  const SENT_MAX = 1000
13
+ const PAIR_MESSAGE_LIMIT = 10
14
+ const PAIR_WINDOW_MS = 60_000
13
15
  const receiptMeaning = 'claimed 仅表示目标 turn 已从 Inbox 认领消息;传输回执不表示对方已读、回复或完成。'
14
16
  /** 插件恢复的 Session handle;保留到插件卸载,避免 idle dispose 移除 Harness store 投影。 */
15
17
  const resumedHandles = new Map()
18
+ /** 无向 Session 对 -> 最近成功或正在进行的投递预留;只保护当前 Harness 进程。 */
19
+ const pairSends = new Map()
20
+ let lastPairPruneAt = 0
16
21
 
17
22
  ctx.effect(() => async () => {
18
23
  const handles = [...resumedHandles.values()]
@@ -37,6 +42,34 @@ export function apply(ctx) {
37
42
  if (sent.size > SENT_MAX) sent.delete(sent.keys().next().value)
38
43
  }
39
44
 
45
+ function reservePairSend(from, to) {
46
+ const now = Date.now()
47
+ if (now < lastPairPruneAt || now - lastPairPruneAt >= PAIR_WINDOW_MS) {
48
+ for (const [key, entries] of pairSends) {
49
+ const active = entries.filter((entry) => now - entry.at < PAIR_WINDOW_MS)
50
+ if (active.length === 0) pairSends.delete(key)
51
+ else pairSends.set(key, active)
52
+ }
53
+ lastPairPruneAt = now
54
+ }
55
+ const key = JSON.stringify([String(from), String(to)].sort())
56
+ const recent = (pairSends.get(key) ?? []).filter((entry) => now - entry.at < PAIR_WINDOW_MS)
57
+ if (recent.length >= PAIR_MESSAGE_LIMIT) {
58
+ pairSends.set(key, recent)
59
+ throw new Error('同一对会话 60 秒内最多投递 10 条消息,请稍后再试')
60
+ }
61
+ const reservation = { at: now }
62
+ recent.push(reservation)
63
+ pairSends.set(key, recent)
64
+ return () => {
65
+ const current = pairSends.get(key)
66
+ if (current === undefined) return
67
+ const index = current.indexOf(reservation)
68
+ if (index !== -1) current.splice(index, 1)
69
+ if (current.length === 0) pairSends.delete(key)
70
+ }
71
+ }
72
+
40
73
  function titleOf(agent) {
41
74
  const service = ctx.get('sessionTitle')
42
75
  if (service !== undefined) {
@@ -71,6 +104,12 @@ export function apply(ctx) {
71
104
  return header?.origin === 'subagent'
72
105
  }
73
106
 
107
+ function assertPeerCaller(agent) {
108
+ if (agent !== undefined && isSubagentSession(agent.session.header)) {
109
+ throw new Error('子代理不能使用独立会话通信工具')
110
+ }
111
+ }
112
+
74
113
  function sessionQuery() {
75
114
  const query = ctx.get('sessionQuery')
76
115
  if (query === undefined) throw new Error('本部署缺少 sessionQuery,无法查询逻辑会话')
@@ -180,6 +219,7 @@ export function apply(ctx) {
180
219
  },
181
220
  async execute(_args, exec) {
182
221
  const me = exec.agent
222
+ assertPeerCaller(me)
183
223
  const archived = archivedIds()
184
224
  const query = sessionQuery()
185
225
  const live = new Map()
@@ -230,6 +270,7 @@ export function apply(ctx) {
230
270
  '默认使用 followup 创建独立的新 turn;' +
231
271
  '目标离线(进程里未加载)时自动恢复该会话后投递。' +
232
272
  '用户无需说出模式名:目标为 running 且整句明确要求立即介入时用 steer,明确要求不打断当前任务、只补充上下文时用 inject;不确定时使用 followup。' +
273
+ '同一对 Session 双向合计 60 秒内最多投递 10 条消息,超过时拒绝本次投递。' +
233
274
  '归档会话和子代理一律拒绝。' +
234
275
  '注意:它不同于 send_message(后者是给你的后台子代理续聊)。',
235
276
  parameters: {
@@ -239,15 +280,31 @@ export function apply(ctx) {
239
280
  },
240
281
  output: {
241
282
  schema: { type: 'json' },
242
- render(_args, value) {
243
- return [{ type: 'text', text: value.text || JSON.stringify(value) }]
283
+ render() {
284
+ return [{ type: 'text', text: '已投递。' }]
244
285
  },
286
+ presentationMeta(_args, value) {
287
+ return value
288
+ },
289
+ },
290
+ presentResult(_args, result) {
291
+ const meta = result.meta
292
+ if (result.isError || meta === null || typeof meta !== 'object' || Array.isArray(meta)) return
293
+ return {
294
+ card: 'generic',
295
+ title: '消息已投递',
296
+ content: [{ type: 'text', text: meta.text || JSON.stringify(meta, null, 2) }],
297
+ }
245
298
  },
246
299
  async execute(args, exec) {
247
300
  const me = exec.agent
248
301
  if (me === undefined) throw new Error('no calling agent')
302
+ assertPeerCaller(me)
249
303
  const to = args.to
250
304
  if (to === '' || String(to) === String(me.id)) throw new Error('不能给自己发消息')
305
+ if (args.content.includes('<dsh-agent-message>') || args.content.includes('</dsh-agent-message>')) {
306
+ throw new Error('消息正文不能包含保留协议标签 dsh-agent-message')
307
+ }
251
308
 
252
309
  const archived = archivedIds()
253
310
  if (archived.has(String(to))) throw new Error('对方会话已归档,无法发送(请先取消归档)')
@@ -260,6 +317,14 @@ export function apply(ctx) {
260
317
  throw new Error(mode + ' 仅用于 running 会话;目标当前状态:' + target.status)
261
318
  }
262
319
 
320
+ let inspected
321
+ if (target === undefined) {
322
+ inspected = await readLogicalSession(to)
323
+ if (inspected === undefined) throw new Error('会话不存在:' + to)
324
+ if (isSubagentSession(inspected.session)) throw new Error('目标是子代理,不能通过会话通信插件直接发送')
325
+ if (mode !== 'followup') throw new Error('目标离线(进程里未加载):' + mode + ' 仅用于 running 会话')
326
+ }
327
+
263
328
  const myTitle = titleOf(me) || String(me.id)
264
329
  const source = {
265
330
  kind: name,
@@ -279,19 +344,21 @@ export function apply(ctx) {
279
344
  let usedMode = ''
280
345
  let targetRuntimeStatus = 'offline'
281
346
 
282
- if (target !== undefined) {
283
- if (mode === 'followup') { target.followup(message); usedMode = mode }
284
- else if (mode === 'inject') { target.inject(message); usedMode = 'inject' }
285
- else { target.steer(message); usedMode = 'steer' }
286
- targetRuntimeStatus = target.status
287
- } else {
288
- const inspected = await readLogicalSession(to)
289
- if (inspected === undefined) throw new Error('会话不存在:' + to)
290
- if (isSubagentSession(inspected.session)) throw new Error('目标是子代理,不能通过会话通信插件直接发送')
291
- if (mode !== 'followup') throw new Error('目标离线(进程里未加载):' + mode + ' 仅用于 running 会话')
292
- const resumed = await resumeAndFollowup(to, message, inspected)
293
- usedMode = 'followup'
294
- targetRuntimeStatus = resumed.status
347
+ const rollbackPairSend = reservePairSend(me.id, to)
348
+ try {
349
+ if (target !== undefined) {
350
+ if (mode === 'followup') { target.followup(message); usedMode = mode }
351
+ else if (mode === 'inject') { target.inject(message); usedMode = 'inject' }
352
+ else { target.steer(message); usedMode = 'steer' }
353
+ targetRuntimeStatus = target.status
354
+ } else {
355
+ const resumed = await resumeAndFollowup(to, message, inspected)
356
+ usedMode = 'followup'
357
+ targetRuntimeStatus = resumed.status
358
+ }
359
+ } catch (error) {
360
+ rollbackPairSend()
361
+ throw error
295
362
  }
296
363
 
297
364
  rememberSent(message.id, String(to), usedMode)
@@ -324,7 +391,8 @@ export function apply(ctx) {
324
391
  return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
325
392
  },
326
393
  },
327
- async execute(args) {
394
+ async execute(args, exec) {
395
+ assertPeerCaller(exec?.agent)
328
396
  const target = agents.get(args.to)
329
397
  const targetRuntimeStatus = target !== undefined ? target.status : 'offline'
330
398
  if (args.messageId !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agent-message",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "packageManager": "pnpm@11.1.1",
5
5
  "description": "跨会话 Agent 通信:让 DeepSeek Harness 里不同的 Agent 会话互相收发消息。",
6
6
  "keywords": ["deepseek", "deepseek-harness", "agent", "multi-agent", "messaging"],