dsh-agent-message 1.5.0 → 1.6.0

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.
@@ -25,15 +25,16 @@ Typical scenarios: an orchestrator Agent dispatching work to a developer Agent,
25
25
  | `list_peer_agents` | List all **sendable independent sessions**: non-archived, excluding actual subagents while retaining ordinary forks; returns id, title, working directory, and runtime status |
26
26
  | `send_agent_message` | Send a message to a session id; `followup` creates an independent turn by default and offline targets are resumed automatically; explicit modes are `followup`, `inject`, and `steer` |
27
27
  | `check_delivery` | Query receipts on demand (pending/claimed/discarded/unknown); a successful send first returns accepted, while pre-admission failure is a tool error; explicit message ids remain queryable after restart |
28
- | `@` session locator | Type `@` at the beginning of the composer and choose a target; candidates show only the session title and `Running`/`Idle`, excluding blank placeholders and subagents. The user sees a readable title while the current Agent receives the stable session id; `@` only locates the session, and the full-sentence intent determines whether to send, read, or analyze |
29
- | Visible Agent message card | Relay keeps its true plugin provenance while the Client presents it as a left-aligned Agent message card; `From Session · <name>:` opens the sender by click or keyboard |
28
+ | Native Harness `@` session reference | Reuse Harness's structured Session Reference directly. The current Agent receives the stable Session ID plus a bounded, read-only snapshot marked as untrusted. Referencing alone does not send a message or wake the target |
29
+ | Visible Agent message card | Relay keeps its true plugin provenance while the Client presents it as a left-aligned Agent message card; `From Session · <name>:` opens the sender by click or keyboard, with labels following the Harness language setting |
30
30
  | Copy session id | A "Copy ID" button is added to the session header for one-click copying of the current session id |
31
+ | Delivery card settings | In Settings → Plugin configuration, toggle delivery mode and target runtime details; `messageId` is always shown |
31
32
 
32
33
  ### Sender navigation example
33
34
 
34
35
  ![Clickable sender header example](./docs/assets/message-header-navigation.jpg)
35
36
 
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.
37
+ 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
38
 
38
39
  ### Delivery modes (the `mode` parameter of `send_agent_message`)
39
40
 
@@ -55,7 +56,7 @@ dsh plugin --profile web add dsh-agent-message
55
56
 
56
57
  It self-registers on install; no extra configuration is needed.
57
58
 
58
- Compatibility: Node.js 24 and DeepSeek Harness `>=0.1.0-rc.6 <0.2.0`; currently verified with Node.js `24.x` and Harness `0.1.0-rc.6`.
59
+ Compatibility: Node.js 24 and DeepSeek Harness `>=0.1.1-rc.2 <0.2.0`; currently verified with Node.js `24.x` and Harness `0.1.1-rc.2`.
59
60
 
60
61
  ### Option 2: Install from GitHub
61
62
 
@@ -77,9 +78,9 @@ The plugin ships a `cordis.patch.yml` (pointed to by `dsh.bundle.patch` in `pack
77
78
 
78
79
  ## Usage
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
+ 1. Type `@` in session A's composer and choose the target session from Harness's native candidate menu;
82
+ 2. Harness gives A a bounded, read-only, untrusted snapshot of that Session, but does not message or wake B. A calls `send_agent_message` only when the current request or an orchestration responsibility already granted by the user explicitly requires cross-session communication. For example, `@B tell it to stop after opening the draft PR` sends, while `@B analyze its latest conversation result` only uses the referenced snapshot;
83
+ 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
84
  4. You can still ask the Agent to call `list_peer_agents` and send directly with a full session id;
84
85
  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
86
  6. (Supervision) Say "check the status of my messages to `<session id>`" — it calls `check_delivery`.
@@ -99,13 +100,15 @@ Delivery paths of `send_agent_message`:
99
100
 
100
101
  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
102
 
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.
103
+ After `send_agent_message` submits the native message to the target Inbox, it immediately returns `accepted` with the native `messageId`; both the terse tool result and the full tool card show a selectable `messageId` for copying, while the complete result also 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
104
 
104
105
  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
106
 
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.
107
+ 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
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
+ Composer-side `@` is provided entirely by Harness's native `ui-reference` / `session-reference` services. Harness retains the stable Session ID in the structured reference and owns bounded snapshot loading and injection. The plugin no longer registers an `@` source, parses titles for addressing, or maintains a second session-reference UI.
110
+
111
+ See [`docs/architecture-v2.md`](./docs/architecture-v2.md) for the current architecture contract.
109
112
 
110
113
  ## Directory structure
111
114
 
@@ -113,21 +116,30 @@ The composer-side `@` session locator reuses Harness's native `inputTriggers` co
113
116
  dsh-agent-message/
114
117
  ├── lib/
115
118
  │ ├── index.js # host half: list_peer_agents / send_agent_message / check_delivery
116
- │ └── client.js # client half: @session references, session navigation and copy-session-id button
119
+ │ └── client.js # client half: relay display, sender navigation, settings, and copy-session-id button
117
120
  ├── cordis.patch.yml # self-registration patch (pointed to by dsh.bundle.patch)
118
121
  ├── package.json # DSH plugin manifest (dsh.bundle / dsh.client / dshx.contributes)
119
- ├── docs/ # design notes and README example screenshot
122
+ ├── docs/ # active/proposed architecture and README example screenshot
123
+ ├── scripts/web-smoke.mjs # real Web Profile startup and configuration smoke test
120
124
  ├── README.md # Chinese documentation
121
125
  └── README.en.md # English documentation
122
126
  ```
123
127
 
124
- ## In Development
128
+ ## Development validation
129
+
130
+ ```sh
131
+ pnpm test
132
+ pnpm run test:web-smoke
133
+ pnpm peers check
134
+ ```
125
135
 
126
- - **Cross-process communication:** Allow Agent sessions running in different DSH processes to exchange messages.
136
+ `test:web-smoke` requires a local `dsh` installation and a configured Web Profile. It checks startup, plugin composition, and HTTP reachability only; see the [architecture validation record](./docs/architecture-v2.md#14-验证记录) for real interaction coverage.
127
137
 
128
138
  ## Limitations
129
139
 
130
140
  - The target session must be **non-archived** and present in local persistence; archived sessions are always rejected.
141
+ - These tools are only for communication between independent sessions; actual subagents are neither listed as targets nor allowed to call them.
142
+ - 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
143
  - 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
144
  - 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
145
  - 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。
@@ -25,15 +25,16 @@
25
25
  | `list_peer_agents` | 列出所有**可发送**的独立会话:未归档、排除真实子代理;普通 fork 保留。返回 id、标题、工作目录和运行状态 |
26
26
  | `send_agent_message` | 给指定会话 ID 发消息;默认使用 `followup` 创建独立的新 turn,离线时自动恢复后投递;显式支持 `followup`、`inject`、`steer` |
27
27
  | `check_delivery` | 按需查询消息回执(pending/claimed/discarded/unknown);发送成功时先返回 accepted,接纳前失败由工具错误表示;指定消息 ID 时支持重启后恢复查询 |
28
- | `@` 会话定位 | 在输入框开头键入 `@` 选择目标;候选只显示会话标题和“运行中/空闲”,空白占位和子代理不混入。选择后用户看到可读标题,当前 Agent 收到稳定 session ID;`@` 只定位,发送、读取或分析由整句意图决定 |
29
- | 可见的 Agent 消息卡片 | relay 仍保留真实的插件来源,但 Client 将它显示为左侧 Agent 消息卡片;`From Session · <名称>:` 可点击或通过键盘打开发送方会话 |
28
+ | Harness 原生 `@` 会话引用 | 直接复用 Harness 的结构化 Session Reference;选择后当前 Agent 获得稳定 Session ID 和一份有界、只读、标记为不可信的会话快照。引用本身不会发送消息或唤醒目标 |
29
+ | 可见的 Agent 消息卡片 | relay 仍保留真实的插件来源,但 Client 将它显示为左侧 Agent 消息卡片;`From Session · <名称>:` 可点击或通过键盘打开发送方会话,文案跟随 Harness 语言设置 |
30
30
  | 复制会话 ID | 会话头部新增「复制ID」按钮,一键复制当前会话 ID |
31
+ | 投递卡片设置 | 在设置 → 插件配置中,可切换是否显示投递模式和目标运行状态;`messageId` 始终显示 |
31
32
 
32
33
  ### 发送方导航示例
33
34
 
34
35
  ![可点击的发送者消息头示例](./docs/assets/message-header-navigation.jpg)
35
36
 
36
- 图中是历史 `user` 气泡的导航效果;当前 relay 消息使用相同的可见卡片和发送方跳转,但持久化来源仍是插件 `relay`,不会伪装成人类输入。完整会话 ID 同时保留在 typed source 和 Host 生成的模型可见协议头中,避免接收 Agent 猜测发送方。
37
+ 当前 relay 消息显示为可见的 Agent 消息卡片;点击消息头即可跳转到发送方会话。持久化来源仍是插件 `relay`,不会伪装成人类输入。完整会话 ID 同时保留在 typed source 和 Host 生成的模型可见协议头中,避免接收 Agent 猜测发送方。
37
38
 
38
39
  ### 投递模式(`send_agent_message` 的 `mode` 参数)
39
40
 
@@ -56,7 +57,7 @@ dsh plugin --profile web add dsh-agent-message
56
57
 
57
58
  装完即自动注册,无需任何额外配置。
58
59
 
59
- 兼容范围:Node.js 24、DeepSeek Harness `>=0.1.0-rc.6 <0.2.0`;当前验证版本为 Node.js `24.x`、Harness `0.1.0-rc.6`。
60
+ 兼容范围:Node.js 24、DeepSeek Harness `>=0.1.1-rc.2 <0.2.0`;当前验证版本为 Node.js `24.x`、Harness `0.1.1-rc.2`。
60
61
 
61
62
  ### 方式二:从 GitHub 安装
62
63
 
@@ -78,9 +79,9 @@ Agent 会用 bash 执行这条命令,装完自动挂载、所有会话立即
78
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
+ 1. 在会话 A 的输入框中键入 `@`,从 Harness 原生候选菜单中选择目标会话;
83
+ 2. Harness 会把该 Session 的有界、只读、不可信快照提供给 A,但不会向 B 发消息或唤醒 B。只有当前请求或用户已授予的编排职责明确要求跨会话传递信息时,A 才调用 `send_agent_message`。例如 `@B 告诉他最后提交 PR draft 就停止` 会发送;`@B 帮我分析他最新的对话结果` 只使用引用快照;
84
+ 3. 显式要求转告时,A 只负责投递并报告“已接受”或失败,不代为执行被转发的任务,也不要求 B 额外回复“收到”;如果正文明确要求 B 把业务内容返回 A,B 才向 `senderSessionId` 发送消息;
84
85
  4. 也可以让 Agent 调 `list_peer_agents`,再用完整会话 ID 直接发送;
85
86
  5. 会话 B 收到的是带 typed relay source 的原生 `UserMessage`;正文首行还有 Host 生成的最小来源协议,B 不需要猜测发送方;Client 将其显示为可见 Agent 消息卡片,并可从消息头打开发送方会话;
86
87
  6. (监督场景)说「查一下我发给 `<会话ID>` 的消息状态」——它会调 `check_delivery`。
@@ -100,13 +101,15 @@ Agent 会用 bash 执行这条命令,装完自动挂载、所有会话立即
100
101
 
101
102
  会话枚举、批量标题和离线日志读取分别使用 Harness 的 `sessionQuery.listSessions()`、`readTitleSnapshots()` 与 `readSession()`。`SessionId` 是唯一地址;`parentSession` 只记录分叉血缘,只有 `origin: subagent` 才会被识别为真实子代理。插件不直接扫描 `sessionPersistence` 重建另一份会话目录。
102
103
 
103
- `send_agent_message` 成功把原生消息提交给目标 Inbox 后立即返回 `accepted` 和该消息的原生 `messageId`;`check_delivery` 根据 Inbox 事件返回 `pending`(仍在排队)、`claimed`(已被某轮认领)、`discarded`(被取消)或 `unknown`。`claimed` 只是传输证据,不表示已读、回复或任务完成。接纳前失败由 Harness 工具错误表示,不写入目标 Inbox。目标是否正在运行通过独立的 `targetRuntimeStatus` 返回,不把 Agent 的整体运行状态误当成某条消息正在处理。指定 `messageId` 时可从目标现有 Inbox 日志恢复状态,因此进程重启后仍可查询。
104
+ `send_agent_message` 成功把原生消息提交给目标 Inbox 后立即返回 `accepted` 和该消息的原生 `messageId`;精简工具结果和完整工具卡片都会显示可选择复制的 `messageId`,完整结果同时保留在工具呈现元数据中。`check_delivery` 根据 Inbox 事件按需返回 `pending`(仍在排队)、`claimed`(已被某轮认领)、`discarded`(被取消)或 `unknown`。`claimed` 只是传输证据,不表示已读、回复或任务完成。接纳前失败由 Harness 工具错误表示,不写入目标 Inbox。目标是否正在运行通过独立的 `targetRuntimeStatus` 返回,不把 Agent 的整体运行状态误当成某条消息正在处理。指定 `messageId` 时可从目标现有 Inbox 日志恢复状态,因此进程重启后仍可查询。
104
105
 
105
106
  所有跨会话消息都由 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
107
 
107
- 当前版本没有额外的结果协议:relay 只表达“另一会话发来的消息”,本身不等于必须回复或禁止回复。正文明确要求返回业务结果时,接收 Agent 可用同一工具向 `senderSessionId` 发送结果;没有明确要求时不回传 transport ack 或单纯的“收到”。需要机器可核验的请求/结果关联时,再引入独立 Result 协议。
108
+ relay 只表达“另一会话发来的消息”,本身不等于必须回复或禁止回复。正文明确要求返回业务内容时,接收 Agent 可用同一工具向 `senderSessionId` 发送消息;没有明确要求时不回传 transport ack 或单纯的“收到”。插件不自动关联请求与回复,也不自动转发 Agent 的普通回答。
108
109
 
109
- 输入框的 `@` 会话定位复用 Harness 原生 `inputTriggers` 命令标记:选择后的可见标题最多 40 Unicode 字符,超出用省略号;提交给当前 Agent 时换成完整 `@session-...` 稳定 ID。发送后的气泡依然用聊天图标和实时会话标题投影该 ID,显示名称变化不会改变定位目标。
110
+ 输入框的 `@` 完全由 Harness 原生 `ui-reference` / `session-reference` 提供,结构化引用保留稳定 Session ID,并由 Harness 负责读取和注入有界快照。插件不再注册自己的 `@` source、不解析标题寻址,也不维护第二套会话引用 UI。
111
+
112
+ 完整的现役架构合同见 [`docs/architecture-v2.md`](./docs/architecture-v2.md)。
110
113
 
111
114
  ## 目录结构
112
115
 
@@ -114,21 +117,30 @@ Agent 会用 bash 执行这条命令,装完自动挂载、所有会话立即
114
117
  dsh-agent-message/
115
118
  ├── lib/
116
119
  │ ├── index.js # host 半区:list_peer_agents / send_agent_message / check_delivery
117
- │ └── client.js # client 半区:@会话引用、会话导航与复制会话ID按钮
120
+ │ └── client.js # client 半区:relay 显示、发送方导航、设置与复制会话ID按钮
118
121
  ├── cordis.patch.yml # 自注册补丁(dsh.bundle.patch 指向它)
119
122
  ├── package.json # DSH 插件清单(dsh.bundle / dsh.client / dshx.contributes)
120
- ├── docs/ # 设计稿与 README 示例截图
123
+ ├── docs/ # 现役/候选架构与 README 示例截图
124
+ ├── scripts/web-smoke.mjs # 真实 Web Profile 启动与配置冒烟
121
125
  ├── README.md # 中文文档
122
126
  └── README.en.md # English documentation
123
127
  ```
124
128
 
125
- ## 正在开发
129
+ ## 开发验证
130
+
131
+ ```sh
132
+ pnpm test
133
+ pnpm run test:web-smoke
134
+ pnpm peers check
135
+ ```
126
136
 
127
- - **跨进程通信**:让运行在不同 DSH 进程中的 Agent 会话也能互相收发消息。
137
+ `test:web-smoke` 需要本机已安装 `dsh` 并配置 Web Profile,它只验证启动、插件组合和 HTTP 可达性;真实交互验收记录见 [`docs/architecture-v2.md`](./docs/architecture-v2.md#14-验证记录)。
128
138
 
129
139
  ## 限制
130
140
 
131
141
  - 目标会话必须**未归档**且存在于本机持久化里;归档会话一律拒绝发送。
142
+ - 工具只用于独立 Session 之间通信;真实子代理既不会出现在目标列表中,也不能作为调用方使用这些工具。
143
+ - 同一对 Session(不分发送方向)在滚动 60 秒内最多投递 10 条消息;第 11 条会在写入目标 Inbox 前被拒绝。该窗口只属于当前 Harness 进程,重启后清空。
132
144
  - 自动恢复离线会话时会使用**默认模型**(不继承它上次手动切换的模型选择);恢复失败时消息不会被写入目标 Inbox。
133
145
  - 不指定 `messageId` 的批量回执依赖内存记账,只覆盖本进程最近 1000 条发送记录(FIFO 淘汰);进程重启后仍可凭已知 `messageId` 查询,但不再返回易失的 `sentAt` 和 `mode`。
134
146
  - 跨进程/跨机器通信不在本插件范围内。
package/lib/client.js CHANGED
@@ -5,152 +5,106 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  const React = require("react");
8
- const { createRoot } = require("react-dom/client");
9
- const { IconQueueOutline14, StateDot } = require("@deepseek-ai/dsh-client-ui-primitives");
10
8
 
11
9
  const name = "dsh-agent-message-client";
12
- const inject = ["slots", "timer", "sessions", "inputTriggers", "workspaces"];
10
+ const inject = ["slots", "timer", "sessions", "settingsScope", "locale"];
13
11
 
14
12
  function apply(ctx) {
15
- const senderSelector = '[data-ref-chip="subagent"], [data-context-relay-sender]';
16
- const referenceSource = "agent-message-session";
17
- const candidateRows = new WeakMap();
18
- const mountedRoots = new Map();
13
+ const senderSelector = "[data-context-relay-sender]";
19
14
  const sessionLinks = new Set();
20
- const workspaces = ctx.get("workspaces");
15
+ const subscribeLocale = function (listener) { return ctx.locale.subscribe(listener); };
16
+ const getLocaleSnapshot = function () { return ctx.locale.getSnapshot(); };
21
17
 
22
- function uiText(zh, en) {
23
- const language = String(document.documentElement.lang || navigator.language || "zh").toLowerCase();
24
- return language.startsWith("zh") ? zh : en;
25
- }
26
-
27
- function sessionRows(includeArchived) {
28
- const snapshot = ctx.sessions.list.getSnapshot();
29
- const archived = new Set(workspaces?.list.getSnapshot().archivedSessionIds || []);
30
- return snapshot.ids
31
- .map(function (id) { return snapshot.byId[id]; })
32
- .filter(function (row) {
33
- return row && !row.blank && row.origin !== "subagent"
34
- && (includeArchived || !archived.has(row.id));
35
- });
36
- }
37
-
38
- function sessionRow(id) {
39
- return ctx.sessions.list.getSnapshot().byId[id];
40
- }
41
-
42
- function sessionTitle(row) {
43
- return String(row?.displayTitle || row?.title || row?.id || "");
44
- }
18
+ const AGENT_MESSAGE_SETTINGS_NAMESPACE = "agent-message";
45
19
 
46
- function compactSessionTitle(row) {
47
- const chars = Array.from(sessionTitle(row));
48
- return chars.length > 40 ? chars.slice(0, 39).join("") + "…" : chars.join("");
20
+ function useLocale() {
21
+ React.useSyncExternalStore(subscribeLocale, getLocaleSnapshot);
49
22
  }
50
23
 
51
- function renderInto(host, content) {
52
- let root = mountedRoots.get(host);
53
- if (!root) {
54
- root = createRoot(host);
55
- mountedRoots.set(host, root);
24
+ function AgentMessageSettingsCard(props) {
25
+ useLocale();
26
+ const snapshot = props.useAgentMessageSettings(function (value) { return value; });
27
+ const current = snapshot.value?.showTechnicalDetails !== false;
28
+ const [draft, setDraft] = React.useState(current);
29
+ const [saving, setSaving] = React.useState(false);
30
+ const [error, setError] = React.useState(false);
31
+ React.useEffect(function () { setDraft(current); }, [snapshot.revision, current]);
32
+ const dirty = draft !== current;
33
+ const writable = snapshot.writable && snapshot.status === "ready";
34
+ function save() {
35
+ if (!dirty || saving || !writable) return;
36
+ setSaving(true);
37
+ setError(false);
38
+ props.setShowTechnicalDetails(draft)
39
+ .catch(function () { setError(true); })
40
+ .finally(function () { setSaving(false); });
41
+ }
42
+ function reset() {
43
+ if (!writable) return;
44
+ setDraft(snapshot.base?.showTechnicalDetails !== false);
56
45
  }
57
- root.render(content);
46
+ return React.createElement("section", { style: { padding: "12px 0", maxWidth: "760px" } },
47
+ React.createElement("h3", { style: { margin: "0 0 4px" } }, uiText("跨会话消息", "Agent messages")),
48
+ React.createElement("p", { style: { color: "var(--dsw-alias-label-tertiary)", margin: "0 0 12px", fontSize: "13px" } }, uiText("控制投递卡片是否显示传输细节。messageId 始终保留。", "Control transport details in delivery cards. The messageId is always kept.")),
49
+ React.createElement("label", { style: { display: "flex", gap: "8px", alignItems: "center" } },
50
+ React.createElement("input", { type: "checkbox", checked: draft, disabled: !writable, onChange: function (event) { setDraft(event.target.checked); } }),
51
+ uiText("显示投递模式和目标运行状态", "Show delivery mode and target runtime status")),
52
+ error ? React.createElement("p", { role: "alert", style: { color: "var(--dsw-alias-state-error)", margin: "10px 0 0", fontSize: "13px" } }, uiText("保存失败,设置可能已被其他页面修改。", "Save failed; the setting may have changed elsewhere.")) : null,
53
+ React.createElement("div", { style: { display: "flex", gap: "8px", marginTop: "14px" } },
54
+ React.createElement("button", { type: "button", disabled: !dirty || saving || !writable, onClick: reset }, uiText("恢复默认", "Reset")),
55
+ React.createElement("button", { type: "button", disabled: !dirty || saving || !writable, onClick: save }, saving ? uiText("保存中…", "Saving…") : uiText("保存", "Save")))
56
+ );
58
57
  }
59
58
 
60
- 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);
59
+ if (ctx.settingsScope !== undefined && ctx.slots !== undefined) {
60
+ const scope = ctx.settingsScope.bind({ namespace: AGENT_MESSAGE_SETTINGS_NAMESPACE });
61
+ const agentMessageSettingsStore = {
62
+ getSnapshot: function () { return scope.getSnapshot(); },
63
+ subscribe: function (listener) { return scope.subscribe(listener); },
64
+ };
65
+ ctx.slots.inject("settings.plugin.item", function () {
66
+ return ctx.slots.register({
67
+ name: "settings.plugin.item",
68
+ key: AGENT_MESSAGE_SETTINGS_NAMESPACE,
69
+ inject: function () {
70
+ return {
71
+ hooks: { agentMessageSettings: agentMessageSettingsStore },
72
+ setShowTechnicalDetails: function (value) { return scope.set("showTechnicalDetails", value); },
73
+ };
74
+ },
75
+ }, AgentMessageSettingsCard);
69
76
  });
70
77
  }
71
78
 
72
- function SessionActivity(props) {
73
- return props.running
74
- ? React.createElement(StateDot, { state: "done", size: 10, className: "agent-msg-status-dot" })
75
- : React.createElement("span", { className: "agent-msg-status-dot agent-msg-status-dot-idle", "aria-hidden": "true" });
79
+ function uiText(zh, en) {
80
+ return getLocaleSnapshot().active === "zh" ? zh : en;
76
81
  }
77
82
 
78
- function SessionReference(props) {
79
- return React.createElement("span", { className: "agent-msg-reference-content" },
80
- React.createElement(IconQueueOutline14, { size: 14, className: "agent-msg-session-icon" }),
81
- React.createElement("span", { className: "agent-msg-reference-title" }, "@" + sessionTitle(props.row)));
83
+ function sessionRow(id) {
84
+ return ctx.sessions.list.getSnapshot().byId[id];
82
85
  }
83
86
 
84
- function RelaySender(props) {
85
- return React.createElement("span", { className: "agent-msg-relay-sender-content" },
86
- React.createElement(IconQueueOutline14, { size: 14, className: "agent-msg-session-icon" }),
87
- React.createElement("span", null, "From Session · " + props.title + ":"));
87
+ function sessionTitle(row) {
88
+ return String(row?.displayTitle || row?.title || row?.id || "");
88
89
  }
89
90
 
90
- function enhanceReferenceHost(element, row) {
91
- let host = element.querySelector(":scope > .agent-msg-reference-host");
92
- if (!host) {
93
- element.textContent = "";
94
- host = document.createElement("span");
95
- host.className = "agent-msg-reference-host";
96
- element.appendChild(host);
97
- }
98
- renderInto(host, React.createElement(SessionReference, { row: row }));
91
+ function cleanupRoots(node) {
92
+ if (!(node instanceof Element)) return;
93
+ const links = node.matches(".agent-msg-session-link")
94
+ ? [node].concat(Array.from(node.querySelectorAll(".agent-msg-session-link")))
95
+ : Array.from(node.querySelectorAll(".agent-msg-session-link"));
96
+ links.forEach(function (element) { sessionLinks.delete(element); });
99
97
  }
100
98
 
101
- const inputTriggers = ctx.get("inputTriggers");
102
- if (inputTriggers !== undefined) {
103
- ctx.effect(() => inputTriggers.registerSource({
104
- trigger: "@",
105
- name: referenceSource,
106
- order: 2,
107
- async candidates(session, request) {
108
- if (request.position !== "leading") return [];
109
- const query = String(request.query || "").toLowerCase();
110
- return sessionRows(false)
111
- .filter(function (row) {
112
- return String(row.id) !== String(session.sessionId)
113
- && (query === "" || sessionTitle(row).toLowerCase().includes(query));
114
- })
115
- .sort(function (a, b) {
116
- if (a.running !== b.running) return a.running ? -1 : 1;
117
- return sessionTitle(a).localeCompare(sessionTitle(b));
118
- })
119
- .map(function (row) {
120
- const candidate = {
121
- name: sessionTitle(row),
122
- description: row.running ? uiText("运行中", "Running") : uiText("空闲", "Idle"),
123
- icon: "",
124
- };
125
- candidateRows.set(candidate, row);
126
- return candidate;
127
- });
128
- },
129
- onPick({ candidate, session }) {
130
- const row = candidateRows.get(candidate);
131
- if (!row) return undefined;
132
- const label = "@" + compactSessionTitle(row);
133
- return {
134
- claim: {
135
- token: label + " ",
136
- hint: uiText("输入要处理的内容", "Describe what to do"),
137
- async submit(args) {
138
- const content = String(args || "").trim();
139
- if (content === "") return { kind: "error", text: uiText("请输入要处理的内容", "Describe what to do") };
140
- const binding = ctx.sessions.binding(session.sessionId);
141
- if (!binding) return { kind: "error", text: uiText("当前会话不可用", "Current session is unavailable") };
142
- const result = await binding.session.prompt([{
143
- type: "text",
144
- text: "@" + String(row.id) + " " + content,
145
- }], "queue");
146
- return result.ok
147
- ? { kind: "success" }
148
- : { kind: "error", text: result.error.message };
149
- },
150
- },
151
- };
152
- },
153
- }), "dsh-agent-message: @ session source");
99
+ function restoreSender(element) {
100
+ if (element.dataset.agentMsgOriginalText !== undefined) {
101
+ element.textContent = element.dataset.agentMsgOriginalText;
102
+ }
103
+ delete element.dataset.agentMsgOriginalText;
104
+ delete element.dataset.agentMsgSessionId;
105
+ delete element.dataset.agentMsgSenderTitle;
106
+ element.classList.remove("agent-msg-session-link", "agent-msg-sender-link");
107
+ ["role", "tabindex", "title", "aria-label"].forEach(function (name) { element.removeAttribute(name); });
154
108
  }
155
109
 
156
110
  function senderLink(target) {
@@ -164,25 +118,6 @@ window.__ModuleLoader__.load({
164
118
  return match ? { element: element, sessionId: match[0] } : null;
165
119
  }
166
120
 
167
- function titleFrom(text) {
168
- const line = String(text || "").split("\n", 1)[0].trim();
169
- const current = line.match(/^From (?:Session|Agent)(?: ·)? (.+?):(?: @session-[\w-]+)?$/);
170
- const previous = line.match(/^来自 Agent · (.+)$/);
171
- const legacy = line.match(/^来自 Agent「(.+)」\s*[·::]?$/);
172
- return (current || previous || legacy)?.[1]?.trim() || "";
173
- }
174
-
175
- function senderTitle(element) {
176
- if (element.dataset.agentMsgSenderTitle) return element.dataset.agentMsgSenderTitle;
177
- const label = element.previousElementSibling;
178
- const title = titleFrom(label?.textContent);
179
- if (title && label) {
180
- label.textContent = "";
181
- label.classList.add("agent-msg-sender-prefix");
182
- }
183
- return title;
184
- }
185
-
186
121
  function prepareSenderLinks(root) {
187
122
  if (!(root instanceof Element)) return;
188
123
  const elements = root.matches(senderSelector)
@@ -191,19 +126,19 @@ window.__ModuleLoader__.load({
191
126
  elements.forEach(function (element) {
192
127
  const link = senderLink(element);
193
128
  if (!link) return;
194
- const relay = element.matches("[data-context-relay-sender]");
195
- const title = relay ? sessionTitle(sessionRow(link.sessionId)) || "@" + link.sessionId : senderTitle(element);
196
- if (!relay && !title) return;
129
+ const title = sessionTitle(sessionRow(link.sessionId)) || "@" + link.sessionId;
130
+ if (element.dataset.agentMsgOriginalText === undefined) {
131
+ element.dataset.agentMsgOriginalText = element.textContent || "";
132
+ }
197
133
  element.dataset.agentMsgSessionId = link.sessionId;
198
- if (title) element.dataset.agentMsgSenderTitle = title;
199
- if (relay) renderInto(element, React.createElement(RelaySender, { title: title }));
200
- else element.textContent = "From Session · " + title + ":";
134
+ element.dataset.agentMsgSenderTitle = title;
135
+ element.textContent = uiText("来自会话 · ", "From Session · ") + title + ":";
201
136
  element.classList.add("agent-msg-session-link", "agent-msg-sender-link");
202
137
  sessionLinks.add(element);
203
138
  element.setAttribute("role", "link");
204
139
  element.setAttribute("tabindex", "0");
205
- element.setAttribute("title", "打开发送方会话");
206
- element.setAttribute("aria-label", "打开发送方会话:" + (title || link.sessionId));
140
+ element.setAttribute("title", uiText("打开发送方会话", "Open sender session"));
141
+ element.setAttribute("aria-label", uiText("打开发送方会话:", "Open sender session: ") + (title || link.sessionId));
207
142
  });
208
143
  }
209
144
 
@@ -234,74 +169,18 @@ window.__ModuleLoader__.load({
234
169
  });
235
170
  }
236
171
 
237
- function prepareSessionReferences(root) {
238
- if (!(root instanceof Element)) return;
239
- const elements = root.matches('[data-ref-chip="subagent"]')
240
- ? [root].concat(Array.from(root.querySelectorAll('[data-ref-chip="subagent"]')))
241
- : Array.from(root.querySelectorAll('[data-ref-chip="subagent"]'));
242
- elements.forEach(function (element) {
243
- if (element.classList.contains("agent-msg-sender-link")) return;
244
- const id = element.dataset.agentMsgSessionId
245
- || String(element.textContent || "").trim().match(/^@(session-[\w-]+)$/)?.[1];
246
- const row = id ? sessionRow(id) : undefined;
247
- if (!id || !row) return;
248
- element.dataset.agentMsgSessionId = id;
249
- element.classList.add("agent-msg-session-link", "agent-msg-reference-link");
250
- sessionLinks.add(element);
251
- element.setAttribute("role", "link");
252
- element.setAttribute("tabindex", "0");
253
- element.setAttribute("title", uiText("打开引用会话", "Open referenced session"));
254
- element.setAttribute("aria-label", uiText("打开引用会话:", "Open referenced session: ") + sessionTitle(row));
255
- enhanceReferenceHost(element, row);
256
- });
257
- }
258
-
259
- function prepareSessionMenu(root) {
260
- if (!(root instanceof Element)) return;
261
- const menu = root.matches('[role="listbox"]') ? root : root.closest('[role="listbox"]') || root.querySelector('[role="listbox"]');
262
- if (!menu) return;
263
- const group = menu.querySelector('[data-source="' + referenceSource + '"]');
264
- if (!group) return;
265
- group.textContent = uiText("会话", "Sessions");
266
- let element = group.nextElementSibling;
267
- while (element && !element.hasAttribute("data-source")) {
268
- if (element.matches('button[role="option"]')) {
269
- element.classList.add("agent-msg-session-candidate");
270
- const spans = element.querySelectorAll(":scope > span");
271
- const icon = spans[0];
272
- const description = spans[2];
273
- if (icon) {
274
- icon.classList.add("agent-msg-candidate-icon");
275
- renderInto(icon, React.createElement(IconQueueOutline14, { size: 14 }));
276
- }
277
- if (description) {
278
- const running = /^(运行中|Running)$/.test(String(description.textContent || "").trim())
279
- || element.dataset.agentMsgRunning === "true";
280
- element.dataset.agentMsgRunning = String(running);
281
- renderInto(description, React.createElement("span", { className: "agent-msg-status-label" },
282
- React.createElement(SessionActivity, { running: running }),
283
- React.createElement("span", null, running ? uiText("运行中", "Running") : uiText("空闲", "Idle"))));
284
- }
285
- }
286
- element = element.nextElementSibling;
287
- }
288
- }
289
-
290
172
  function prepare(root) {
291
173
  prepareRelayCards(root);
292
174
  prepareSenderLinks(root);
293
- prepareSessionReferences(root);
294
- prepareSessionMenu(root);
295
175
  }
296
176
 
297
- function refreshSessionLinks() {
177
+ function refreshSenderLinks() {
298
178
  sessionLinks.forEach(function (element) {
299
179
  if (!element.isConnected) {
300
180
  sessionLinks.delete(element);
301
181
  return;
302
182
  }
303
- if (element.classList.contains("agent-msg-sender-link")) prepareSenderLinks(element);
304
- else prepareSessionReferences(element);
183
+ prepareSenderLinks(element);
305
184
  });
306
185
  }
307
186
 
@@ -351,6 +230,7 @@ window.__ModuleLoader__.load({
351
230
  }
352
231
 
353
232
  function CopyButton(props) {
233
+ useLocale();
354
234
  const [copyState, setCopyState] = React.useState("idle");
355
235
  async function onClick() {
356
236
  const text = String(props.sessionId || "");
@@ -363,39 +243,29 @@ window.__ModuleLoader__.load({
363
243
  type: "button",
364
244
  onClick: onClick,
365
245
  onMouseLeave: onMouseLeave,
366
- title: "复制会话 ID",
367
- "aria-label": "复制会话 ID",
246
+ title: uiText("复制会话 ID", "Copy session ID"),
247
+ "aria-label": uiText("复制会话 ID", "Copy session ID"),
368
248
  className: "agent-msg-copy-id"
369
- }, copyState === "copied" ? "已复制" : copyState === "failed" ? "复制失败" : "复制ID");
249
+ }, copyState === "copied"
250
+ ? uiText("已复制", "Copied")
251
+ : copyState === "failed"
252
+ ? uiText("复制失败", "Copy failed")
253
+ : uiText("复制ID", "Copy ID"));
370
254
  }
371
255
 
372
256
  ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register(
373
- { name: "conversation.session.header.actions", id: "copy-session-id", order: 30, label: "复制会话ID" },
257
+ { name: "conversation.session.header.actions", id: "copy-session-id", order: 30, label: uiText("复制会话ID", "Copy session ID") },
374
258
  (props) => React.createElement(CopyButton, { sessionId: props.sessionId })
375
259
  ));
376
260
 
377
261
  const css =
378
262
  ".agent-msg-copy-id { cursor: pointer; font-size: 12px; line-height: 1; padding: 5px 10px; border: 1px solid rgba(127,127,127,.35); border-radius: 6px; background: transparent; color: inherit; opacity: .85; } " +
379
263
  ".agent-msg-copy-id:hover { opacity: 1; border-color: rgba(127,127,127,.7); } " +
380
- ".agent-msg-sender-prefix { display: none !important; } " +
381
264
  ".agent-msg-session-link { cursor: pointer; text-decoration: none; } " +
382
- ".agent-msg-sender-link[data-ref-chip=\"subagent\"] { display: block; width: fit-content; padding: 0; background: transparent; color: inherit; font-size: 13px; font-weight: 600; line-height: 1.5; } " +
383
- ".agent-msg-reference-link { display: inline-flex !important; align-items: center; color: var(--dsw-alias-state-business-primary) !important; } " +
384
- ".agent-msg-reference-host, .agent-msg-reference-content { display: inline-flex; min-width: 0; align-items: center; } " +
385
- ".agent-msg-reference-content { gap: 5px; max-width: 100%; } " +
386
- ".agent-msg-reference-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } " +
387
- ".agent-msg-session-icon, .agent-msg-candidate-icon { display: inline-flex; flex: none; color: var(--dsw-alias-state-business-primary); } " +
388
- ".agent-msg-relay-sender-content { display: inline-flex; align-items: center; gap: 5px; } " +
389
265
  "[data-agent-msg-relay-card] [data-disclosure-row] { display: none !important; } " +
390
266
  "[data-agent-msg-relay-card] [data-context-form=\"relay\"] { width: fit-content; max-width: min(78%, 760px); max-height: none; margin: 0; padding: 14px 16px; overflow: visible; border: 1px solid rgba(127,127,127,.24); border-radius: 16px 16px 16px 4px; background: var(--dsw-alias-markdown-code-block); color: var(--dsw-alias-label-primary); font: inherit; } " +
391
267
  "[data-agent-msg-relay-card] [data-context-relay-sender] { display: block; width: fit-content; margin: 0 0 8px; color: var(--dsw-alias-state-business-primary); font-size: 13px; font-weight: 600; line-height: 1.5; } " +
392
268
  "[data-agent-msg-relay-card] [data-context-text] { margin: 0; overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere; color: inherit; font: inherit; } " +
393
- ".agent-msg-status-dot { flex: none; } " +
394
- ".agent-msg-status-dot-idle { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--dsw-alias-label-tertiary); opacity: .7; } " +
395
- ".agent-msg-status-label { display: inline-flex; align-items: center; gap: 6px; } " +
396
- ".agent-msg-session-candidate { border-radius: 0 !important; } " +
397
- ".agent-msg-session-candidate > span:nth-child(2) { flex: 1; min-width: 0; max-width: none; } " +
398
- ".agent-msg-session-candidate > span:nth-child(3) { display: flex; flex: none; justify-content: flex-end; margin-left: auto; } " +
399
269
  ".agent-msg-session-link:hover { text-decoration: underline; text-underline-offset: 3px; } " +
400
270
  ".agent-msg-session-link:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; border-radius: 2px; }";
401
271
  const tag = document.createElement("style");
@@ -410,18 +280,17 @@ window.__ModuleLoader__.load({
410
280
  });
411
281
  });
412
282
  observer.observe(document.body, { childList: true, subtree: true });
413
- const unsubscribeSessions = ctx.sessions.list.subscribe(refreshSessionLinks);
414
- const unsubscribeWorkspaces = workspaces?.list.subscribe(refreshSessionLinks);
283
+ const unsubscribeSessions = ctx.sessions.list.subscribe(refreshSenderLinks);
284
+ const unsubscribeLocale = ctx.locale.subscribe(refreshSenderLinks);
415
285
  document.addEventListener("click", openSender);
416
286
  document.addEventListener("keydown", onKeyDown);
417
287
  ctx.effect(() => () => {
418
288
  observer.disconnect();
419
289
  unsubscribeSessions();
420
- unsubscribeWorkspaces?.();
290
+ unsubscribeLocale();
421
291
  document.removeEventListener("click", openSender);
422
292
  document.removeEventListener("keydown", onKeyDown);
423
- mountedRoots.forEach(function (root) { root.unmount(); });
424
- mountedRoots.clear();
293
+ sessionLinks.forEach(restoreSender);
425
294
  sessionLinks.clear();
426
295
  tag.remove();
427
296
  });
package/lib/index.js CHANGED
@@ -1,18 +1,39 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm/message'
3
+ import z from '@deepseek-ai/schemastery'
4
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
3
5
 
4
6
  export const name = 'dsh-agent-message'
5
7
  export const inject = ['agents', 'tools', 'sessionQuery']
6
8
 
9
+ const AGENT_MESSAGE_SETTINGS_NAMESPACE = settingsNamespace('agent-message')
10
+ const AGENT_MESSAGE_SETTINGS_SCHEMA = z.object({ showTechnicalDetails: z.boolean().default(true) })
11
+
7
12
  export function apply(ctx) {
8
13
  const agents = ctx.agents
14
+ let settingsSource = () => ({ showTechnicalDetails: true })
9
15
  /** messageId -> { to, at, mode };发送成功即记账,供批量查询及补充本进程发送信息。 */
10
16
  const sent = new Map()
11
17
  /** 记账表 FIFO 上限:超过则淘汰最老记录,内存恒定。 */
12
18
  const SENT_MAX = 1000
19
+ const PAIR_MESSAGE_LIMIT = 10
20
+ const PAIR_WINDOW_MS = 60_000
13
21
  const receiptMeaning = 'claimed 仅表示目标 turn 已从 Inbox 认领消息;传输回执不表示对方已读、回复或完成。'
14
22
  /** 插件恢复的 Session handle;保留到插件卸载,避免 idle dispose 移除 Harness store 投影。 */
15
23
  const resumedHandles = new Map()
24
+ /** 无向 Session 对 -> 最近成功或正在进行的投递预留;只保护当前 Harness 进程。 */
25
+ const pairSends = new Map()
26
+ let lastPairPruneAt = 0
27
+
28
+ // 设置由 Cordis 注入;旧版测试宿主没有 inject 时继续使用默认行为。
29
+ if (typeof ctx.inject === 'function') {
30
+ installSettingsSection(ctx, AGENT_MESSAGE_SETTINGS_NAMESPACE, AGENT_MESSAGE_SETTINGS_SCHEMA, { showTechnicalDetails: true }, {
31
+ setSource(source) {
32
+ settingsSource = source
33
+ },
34
+ onChange() {},
35
+ })
36
+ }
16
37
 
17
38
  ctx.effect(() => async () => {
18
39
  const handles = [...resumedHandles.values()]
@@ -37,6 +58,35 @@ export function apply(ctx) {
37
58
  if (sent.size > SENT_MAX) sent.delete(sent.keys().next().value)
38
59
  }
39
60
 
61
+ function reservePairSend(from, to) {
62
+ const now = Date.now()
63
+ if (now < lastPairPruneAt || now - lastPairPruneAt >= PAIR_WINDOW_MS) {
64
+ for (const [key, entries] of pairSends) {
65
+ const active = entries.filter((entry) => now - entry.at < PAIR_WINDOW_MS)
66
+ if (active.length === 0) pairSends.delete(key)
67
+ else pairSends.set(key, active)
68
+ }
69
+ lastPairPruneAt = now
70
+ }
71
+ const key = JSON.stringify([String(from), String(to)].sort())
72
+ const recent = (pairSends.get(key) ?? []).filter((entry) => now - entry.at < PAIR_WINDOW_MS)
73
+ if (recent.length >= PAIR_MESSAGE_LIMIT) {
74
+ pairSends.set(key, recent)
75
+ const retryAfterSeconds = Math.max(1, Math.ceil((PAIR_WINDOW_MS - (now - recent[0].at)) / 1000))
76
+ throw new Error('同一对会话 60 秒内最多投递 10 条消息;请勿立即重试,约 ' + retryAfterSeconds + ' 秒后窗口重置')
77
+ }
78
+ const reservation = { at: now }
79
+ recent.push(reservation)
80
+ pairSends.set(key, recent)
81
+ return () => {
82
+ const current = pairSends.get(key)
83
+ if (current === undefined) return
84
+ const index = current.indexOf(reservation)
85
+ if (index !== -1) current.splice(index, 1)
86
+ if (current.length === 0) pairSends.delete(key)
87
+ }
88
+ }
89
+
40
90
  function titleOf(agent) {
41
91
  const service = ctx.get('sessionTitle')
42
92
  if (service !== undefined) {
@@ -71,6 +121,12 @@ export function apply(ctx) {
71
121
  return header?.origin === 'subagent'
72
122
  }
73
123
 
124
+ function assertPeerCaller(agent) {
125
+ if (agent !== undefined && isSubagentSession(agent.session.header)) {
126
+ throw new Error('子代理不能使用独立会话通信工具')
127
+ }
128
+ }
129
+
74
130
  function sessionQuery() {
75
131
  const query = ctx.get('sessionQuery')
76
132
  if (query === undefined) throw new Error('本部署缺少 sessionQuery,无法查询逻辑会话')
@@ -180,6 +236,7 @@ export function apply(ctx) {
180
236
  },
181
237
  async execute(_args, exec) {
182
238
  const me = exec.agent
239
+ assertPeerCaller(me)
183
240
  const archived = archivedIds()
184
241
  const query = sessionQuery()
185
242
  const live = new Map()
@@ -230,6 +287,7 @@ export function apply(ctx) {
230
287
  '默认使用 followup 创建独立的新 turn;' +
231
288
  '目标离线(进程里未加载)时自动恢复该会话后投递。' +
232
289
  '用户无需说出模式名:目标为 running 且整句明确要求立即介入时用 steer,明确要求不打断当前任务、只补充上下文时用 inject;不确定时使用 followup。' +
290
+ '同一对 Session 双向合计 60 秒内最多投递 10 条消息,超过时拒绝本次投递。' +
233
291
  '归档会话和子代理一律拒绝。' +
234
292
  '注意:它不同于 send_message(后者是给你的后台子代理续聊)。',
235
293
  parameters: {
@@ -240,14 +298,36 @@ export function apply(ctx) {
240
298
  output: {
241
299
  schema: { type: 'json' },
242
300
  render(_args, value) {
243
- return [{ type: 'text', text: value.text || JSON.stringify(value) }]
301
+ return [{ type: 'text', text: '已投递。\nmessageId: ' + value.messageId }]
302
+ },
303
+ presentationMeta(_args, value) {
304
+ return value
244
305
  },
245
306
  },
307
+ presentResult(_args, result) {
308
+ const meta = result.meta
309
+ if (result.isError || meta === null || typeof meta !== 'object' || Array.isArray(meta)) return
310
+ const messageId = typeof meta.messageId === 'string' ? meta.messageId : ''
311
+ const text = (typeof meta.text === 'string' ? meta.text : '已投递。')
312
+ + (messageId ? '\nmessageId: ' + messageId : '')
313
+ const details = settingsSource().showTechnicalDetails
314
+ ? '\n' + JSON.stringify({ to: meta.to, mode: meta.mode, state: meta.state, targetRuntimeStatus: meta.targetRuntimeStatus })
315
+ : ''
316
+ return {
317
+ card: 'generic',
318
+ title: '消息已投递',
319
+ content: [{ type: 'text', text: text + details }],
320
+ }
321
+ },
246
322
  async execute(args, exec) {
247
323
  const me = exec.agent
248
324
  if (me === undefined) throw new Error('no calling agent')
325
+ assertPeerCaller(me)
249
326
  const to = args.to
250
327
  if (to === '' || String(to) === String(me.id)) throw new Error('不能给自己发消息')
328
+ if (args.content.includes('<dsh-agent-message>') || args.content.includes('</dsh-agent-message>')) {
329
+ throw new Error('消息正文不能包含保留协议标签 dsh-agent-message')
330
+ }
251
331
 
252
332
  const archived = archivedIds()
253
333
  if (archived.has(String(to))) throw new Error('对方会话已归档,无法发送(请先取消归档)')
@@ -260,6 +340,14 @@ export function apply(ctx) {
260
340
  throw new Error(mode + ' 仅用于 running 会话;目标当前状态:' + target.status)
261
341
  }
262
342
 
343
+ let inspected
344
+ if (target === undefined) {
345
+ inspected = await readLogicalSession(to)
346
+ if (inspected === undefined) throw new Error('会话不存在:' + to)
347
+ if (isSubagentSession(inspected.session)) throw new Error('目标是子代理,不能通过会话通信插件直接发送')
348
+ if (mode !== 'followup') throw new Error('目标离线(进程里未加载):' + mode + ' 仅用于 running 会话')
349
+ }
350
+
263
351
  const myTitle = titleOf(me) || String(me.id)
264
352
  const source = {
265
353
  kind: name,
@@ -279,19 +367,21 @@ export function apply(ctx) {
279
367
  let usedMode = ''
280
368
  let targetRuntimeStatus = 'offline'
281
369
 
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
370
+ const rollbackPairSend = reservePairSend(me.id, to)
371
+ try {
372
+ if (target !== undefined) {
373
+ if (mode === 'followup') { target.followup(message); usedMode = mode }
374
+ else if (mode === 'inject') { target.inject(message); usedMode = 'inject' }
375
+ else { target.steer(message); usedMode = 'steer' }
376
+ targetRuntimeStatus = target.status
377
+ } else {
378
+ const resumed = await resumeAndFollowup(to, message, inspected)
379
+ usedMode = 'followup'
380
+ targetRuntimeStatus = resumed.status
381
+ }
382
+ } catch (error) {
383
+ rollbackPairSend()
384
+ throw error
295
385
  }
296
386
 
297
387
  rememberSent(message.id, String(to), usedMode)
@@ -324,7 +414,8 @@ export function apply(ctx) {
324
414
  return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
325
415
  },
326
416
  },
327
- async execute(args) {
417
+ async execute(args, exec) {
418
+ assertPeerCaller(exec?.agent)
328
419
  const target = agents.get(args.to)
329
420
  const targetRuntimeStatus = target !== undefined ? target.status : 'offline'
330
421
  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.6.0",
4
4
  "packageManager": "pnpm@11.1.1",
5
5
  "description": "跨会话 Agent 通信:让 DeepSeek Harness 里不同的 Agent 会话互相收发消息。",
6
6
  "keywords": ["deepseek", "deepseek-harness", "agent", "multi-agent", "messaging"],
@@ -12,7 +12,10 @@
12
12
  },
13
13
  "type": "module",
14
14
  "main": "./lib/index.js",
15
- "scripts": { "test": "node --test" },
15
+ "scripts": {
16
+ "test": "node --test",
17
+ "test:web-smoke": "node scripts/web-smoke.mjs"
18
+ },
16
19
  "exports": {
17
20
  ".": { "default": "./lib/index.js" },
18
21
  "./client": { "default": "./lib/client.js" },
@@ -26,10 +29,11 @@
26
29
  "platform": "web",
27
30
  "inject": [
28
31
  "@deepseek-ai/dsh-client-runtime",
32
+ "@deepseek-ai/dsh-client-locale",
29
33
  "@deepseek-ai/dsh-client-ui-slots",
30
- "@deepseek-ai/dsh-client-ui-conversation",
31
- "@deepseek-ai/dsh-client-ui-input-trigger",
32
- "@deepseek-ai/dsh-client-ui-primitives"
34
+ "@deepseek-ai/dsh-client-ui-settings",
35
+ "@deepseek-ai/dsh-client-ui-settings-plugins",
36
+ "@deepseek-ai/dsh-client-ui-conversation"
33
37
  ]
34
38
  }
35
39
  },
@@ -41,16 +45,20 @@
41
45
  },
42
46
  "peerDependencies": {
43
47
  "@deepseek-ai/cordis": "^4.0.1",
44
- "@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0",
45
- "@deepseek-ai/dsh-tools": ">=0.1.0-rc.6 <0.2.0"
48
+ "@deepseek-ai/dsh-settings": ">=0.1.1-rc.2 <0.2.0",
49
+ "@deepseek-ai/dsh-llm": ">=0.1.1-rc.2 <0.2.0",
50
+ "@deepseek-ai/dsh-tools": ">=0.1.1-rc.2 <0.2.0",
51
+ "@deepseek-ai/schemastery": ">=3.18.1 <4.0.0"
46
52
  },
47
53
  "devDependencies": {
48
- "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
49
- "@deepseek-ai/dsh-tools": "0.1.0-rc.6"
54
+ "@deepseek-ai/dsh-llm": "0.1.1-rc.2",
55
+ "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
56
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.2",
57
+ "@deepseek-ai/schemastery": "3.18.1"
50
58
  },
51
59
  "engines": {
52
60
  "node": ">=24 <25",
53
- "dsh": ">=0.1.0-rc.6 <0.2.0"
61
+ "dsh": ">=0.1.1-rc.2 <0.2.0"
54
62
  },
55
63
  "license": "MIT"
56
64
  }