dsh-agent-message 1.4.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/LICENSE +21 -0
- package/README.en.md +144 -0
- package/README.md +145 -0
- package/cordis.patch.yml +13 -0
- package/docs/assets/message-header-navigation.jpg +0 -0
- package/lib/client.js +170 -0
- package/lib/index.js +370 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GengDaPeng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# dsh-agent-message
|
|
2
|
+
|
|
3
|
+
English | [中文](./README.md)
|
|
4
|
+
|
|
5
|
+
> A cross-session Agent communication plugin for DeepSeek Harness: it lets different Agent sessions running in the same process send and receive messages to each other, just like messaging.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What is this
|
|
12
|
+
|
|
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
|
+
|
|
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** — if the target is online, its current work is steered immediately; if it is offline (not loaded since the last process restart), it is **activated automatically** and then messaged, falling back to a **note** (visible when it is next opened) if activation fails;
|
|
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
|
+
|
|
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.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
| Capability | Description |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `list_peer_agents` | List all **sendable (non-archived)** sessions: id, title, working directory, status (online/offline), kind (peer/subagent) |
|
|
26
|
+
| `send_agent_message` | Send a message to a session id; **immediate delivery by default** (online → steer; offline → wake, falling back to leave), plus five explicit modes |
|
|
27
|
+
| `check_delivery` | Query receipts on demand (delivered/claimed/discarded/unknown); explicit message ids remain queryable after restart, with target runtime status reported separately; silent by default |
|
|
28
|
+
| Navigable sender header | Both the `From Session · <name>:` line in `user` bubbles and the `From session @<ID>` source line in relay contexts can open the sender session by click or keyboard |
|
|
29
|
+
| Copy session id | A "Copy ID" button is added to the session header for one-click copying of the current session id |
|
|
30
|
+
|
|
31
|
+
### Sender navigation example
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
Users see only the sending Agent's name and can open its session by clicking the whole line. The full session id remains available to the receiving Agent in the raw message and metadata for precise identification and replies.
|
|
36
|
+
|
|
37
|
+
### Delivery modes (the `mode` parameter of `send_agent_message`)
|
|
38
|
+
|
|
39
|
+
| mode | Meaning |
|
|
40
|
+
|---|---|
|
|
41
|
+
| (default, omitted) | **Immediate delivery**: online → `steer`; offline → `wake` (falls back to `leave` if activation fails) |
|
|
42
|
+
| `steer` | Steer the target's current work (online only) |
|
|
43
|
+
| `followup` | Queue a new independent turn for the target (online only) |
|
|
44
|
+
| `inject` | Silently inject the next step without waking it up (online only) |
|
|
45
|
+
| `leave` | Leave a note: write into the target's inbox without waking it; for offline sessions this is a "mailbox" that appears when the session is next opened |
|
|
46
|
+
| `wake` | Activate: resume an offline session first, then deliver; equivalent to `followup` when online |
|
|
47
|
+
|
|
48
|
+
**Archived sessions are always rejected** (you are prompted to unarchive first); sending to yourself is also rejected.
|
|
49
|
+
|
|
50
|
+
### Configuring the message form (`form`)
|
|
51
|
+
|
|
52
|
+
The two forms differ not only visually — they carry different **intents**:
|
|
53
|
+
|
|
54
|
+
| form | Rendering | Intent |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `user` (default) | Ordinary message bubble | **Conversational**: like a human chat, a reply is expected |
|
|
57
|
+
| `relay` | Collapsible context block | **Directive**: injected as context that quietly shapes the agent's later behavior — for instruction-style messages where no reply is expected |
|
|
58
|
+
|
|
59
|
+
Override the plugin entry in your profile's `cordis.patch.yml` (takes effect on hot reload, no restart needed):
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
- id: agent-message
|
|
63
|
+
config:
|
|
64
|
+
form: relay
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Installation
|
|
68
|
+
|
|
69
|
+
### Option 1: One-liner (recommended)
|
|
70
|
+
```sh
|
|
71
|
+
dsh plugin --profile web add dsh-agent-message
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
It self-registers on install; no extra configuration is needed.
|
|
75
|
+
|
|
76
|
+
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`.
|
|
77
|
+
|
|
78
|
+
### Option 2: Install from GitHub
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
dsh plugin --profile web add github:GengDaPeng/dsh-agent-message
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Option 3: Just tell your Agent
|
|
85
|
+
|
|
86
|
+
Open any DSH session and send it this message:
|
|
87
|
+
|
|
88
|
+
> Help me install the cross-session communication plugin by running: `dsh plugin --profile web add dsh-agent-message`
|
|
89
|
+
|
|
90
|
+
The Agent will run this command via bash; once installed it auto-mounts and is immediately available to all sessions.
|
|
91
|
+
|
|
92
|
+
### What happens automatically after install
|
|
93
|
+
|
|
94
|
+
The plugin ships a `cordis.patch.yml` (pointed to by `dsh.bundle.patch` in `package.json`) that mounts itself into the host composition on install — so you do **not** need to manually edit presets or `cordis.patch.yml`. All sessions automatically gain `list_peer_agents`, `send_agent_message` and `check_delivery`.
|
|
95
|
+
|
|
96
|
+
## Usage
|
|
97
|
+
|
|
98
|
+
1. Tell session A "list the sendable Agents" — it calls `list_peer_agents`;
|
|
99
|
+
2. Note down the target session's `id` (or have the other side click the "Copy ID" button);
|
|
100
|
+
3. Say "send a message to `<session id>`: ..." — it calls `send_agent_message`; online targets are messaged immediately, offline targets are activated automatically (or left a note on failure);
|
|
101
|
+
4. For `user` messages, click `From Session · <name>:` to open the sender; `relay` keeps Harness's context presentation and exposes the same navigation through its existing `From session @<ID>` source line.
|
|
102
|
+
5. (Supervision) Say "check the status of my messages to `<session id>`" — it calls `check_delivery`.
|
|
103
|
+
|
|
104
|
+
## How it works
|
|
105
|
+
|
|
106
|
+
Each Agent has an inbox `Inbox` containing two FIFO queues:
|
|
107
|
+
|
|
108
|
+
- `next-turn`: messages queued to be processed as an **independent turn**;
|
|
109
|
+
- `next-step`: steering input consumed at the **next step boundary** within the current turn.
|
|
110
|
+
|
|
111
|
+
Delivery paths of `send_agent_message`:
|
|
112
|
+
|
|
113
|
+
- **Online**: find the target Agent through the `agents` registry and write into its inbox via `steer()` / `followup()` / `inject()`;
|
|
114
|
+
- **Offline note (`leave`)**: append an `agent/inbox/spliced` event **durably** to the target session's log — when the session is next resumed, its inbox replays the event and the message is there (and the plugin wakes it so the note appears right away);
|
|
115
|
+
- **Offline activation (`wake`)**: `agents.resume()` restores the session (together with its recorded agent preset), then `followup()` — the session is woken and processes the message immediately.
|
|
116
|
+
|
|
117
|
+
Receipt states come from inbox events: still queued is `delivered`, claimed by one of the target's turns is `claimed`, and cancelled is `discarded`. The target's runtime state is returned separately as `targetStatus`, so an Agent running unrelated work is not presented as processing this message. When `messageId` is specified, `check_delivery` recovers the state directly from the target's existing Inbox log, so the lookup continues to work after a process restart.
|
|
118
|
+
|
|
119
|
+
For `user` messages, the raw body header contains the sender title and full session id while the UI shows only a navigable `From Session · <name>:` header. `relay` does not repeat that header in the body; its native Harness source line is shown as a navigable `From session @<ID>`. Both forms retain the sender title and plain `session-...` id in `source` metadata.
|
|
120
|
+
|
|
121
|
+
## Directory structure
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
dsh-agent-message/
|
|
125
|
+
├── lib/
|
|
126
|
+
│ ├── index.js # host half: list_peer_agents / send_agent_message / check_delivery
|
|
127
|
+
│ └── client.js # client half: sender-session navigation and copy-session-id button
|
|
128
|
+
├── cordis.patch.yml # self-registration patch (pointed to by dsh.bundle.patch)
|
|
129
|
+
├── package.json # DSH plugin manifest (dsh.bundle / dsh.client / dshx.contributes)
|
|
130
|
+
├── docs/ # design notes and README example screenshot
|
|
131
|
+
├── README.md # Chinese documentation
|
|
132
|
+
└── README.en.md # English documentation
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Limitations
|
|
136
|
+
|
|
137
|
+
- The target session must be **non-archived** and present in local persistence; archived sessions are always rejected.
|
|
138
|
+
- Offline activation (`wake`) resumes the target with the **default model** (it does not inherit a model manually selected earlier in that session).
|
|
139
|
+
- 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.
|
|
140
|
+
- Cross-process / cross-machine communication is out of scope.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
[MIT](./LICENSE)
|
package/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# dsh-agent-message
|
|
2
|
+
|
|
3
|
+
[English](./README.en.md) | 中文
|
|
4
|
+
|
|
5
|
+
> DeepSeek Harness 的**跨会话 Agent 通信**插件:让运行在同一个进程里的不同 Agent 会话,像发消息一样互相收发信息。
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 这是什么
|
|
12
|
+
|
|
13
|
+
在 DeepSeek Harness 里,一个进程会同时挂着多个 Agent 会话。本插件给每个会话装上三个工具,让它们能互相"发消息":
|
|
14
|
+
|
|
15
|
+
- 发消息前,先**列出所有可发送的会话**(未归档的都在列,含离线未打开的),按标题找到目标;
|
|
16
|
+
- 找到后,**把消息投递到目标会话**——目标在线就立即引导它当前的工作;目标离线(进程重启后还没打开)就**自动把它激活**再投递,激活失败则自动转为**留言**(下次打开可见);
|
|
17
|
+
- 需要时,可以**按需查询**某条消息的送达状态(排队中/已认领/被丢弃/未知),并单独查看目标是否正在运行,供监督场景使用。
|
|
18
|
+
|
|
19
|
+
典型场景:编排者 Agent 给开发 Agent 派活、两个 Agent 协作接力、主会话给测试会话发指令、监督者 Agent 盯梢多个 worker。
|
|
20
|
+
|
|
21
|
+
## 功能
|
|
22
|
+
|
|
23
|
+
| 能力 | 说明 |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `list_peer_agents` | 列出所有**可发送(未归档)**的会话:id、标题、工作目录、状态(在线/离线)、类型(平级/子代理) |
|
|
26
|
+
| `send_agent_message` | 给指定会话 ID 发消息;默认**立即送达**(在线引导 / 离线激活,失败兜底留言),支持五种显式模式 |
|
|
27
|
+
| `check_delivery` | 按需查询消息回执(delivered/claimed/discarded/unknown);指定消息 ID 时支持重启后恢复查询,并单独返回目标运行状态,默认零播报 |
|
|
28
|
+
| 可导航的发送者消息头 | `user` 气泡的整行 `From Session · <名称>:` 和 relay 的 `From session @<ID>` 来源行均可点击或通过键盘打开发送方会话 |
|
|
29
|
+
| 复制会话 ID | 会话头部新增「复制ID」按钮,一键复制当前会话 ID |
|
|
30
|
+
|
|
31
|
+
### 消息头跳转示例
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
用户只看到发送 Agent 的名称;点击整行即可打开发送方会话。完整会话 ID 仍保留在原始消息和元数据中,供接收 Agent 准确识别与回复。
|
|
36
|
+
|
|
37
|
+
### 投递模式(`send_agent_message` 的 `mode` 参数)
|
|
38
|
+
|
|
39
|
+
| mode | 含义 |
|
|
40
|
+
|---|---|
|
|
41
|
+
| (默认,不传) | **立即送达**:在线 → `steer`;离线 → `wake`(激活失败自动转 `leave`) |
|
|
42
|
+
| `steer` | 引导对方当前的工作(仅在线) |
|
|
43
|
+
| `followup` | 给对方排一条新的独立轮次(仅在线) |
|
|
44
|
+
| `inject` | 静默注入下一步,但不唤醒(仅在线) |
|
|
45
|
+
| `leave` | 留言:写进对方收件箱但不唤醒;离线会话就是"留言板" |
|
|
46
|
+
| `wake` | 激活:离线会话先 `resume` 再投递;在线等价于 `followup` |
|
|
47
|
+
|
|
48
|
+
**归档的会话一律拒绝发送**(提示先取消归档);发给自己也会被拒绝。
|
|
49
|
+
|
|
50
|
+
### 配置:消息渲染形态(`form`)
|
|
51
|
+
|
|
52
|
+
两种形态不只是视觉差异,**承载的意图也不同**:
|
|
53
|
+
|
|
54
|
+
| form | 渲染 | 意图 |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `user`(默认) | 普通消息气泡 | **对话式**:像人发消息一样,期待代理回复 |
|
|
57
|
+
| `relay` | 折叠的上下文块 | **引导式**:作为上下文注入,静默影响代理的后续行为,适合指令类消息,不期待回复 |
|
|
58
|
+
|
|
59
|
+
在你的 profile 的 `cordis.patch.yml` 里覆盖插件条目即可(热更新即时生效,无需重启):
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
- id: agent-message
|
|
63
|
+
config:
|
|
64
|
+
form: relay
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 安装
|
|
68
|
+
|
|
69
|
+
### 方式一:一行命令(推荐)
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
dsh plugin --profile web add dsh-agent-message
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
装完即自动注册,无需任何额外配置。
|
|
76
|
+
|
|
77
|
+
兼容范围:Node.js 24、DeepSeek Harness `>=0.1.0-rc.6 <0.2.0`;当前验证版本为 Node.js `24.x`、Harness `0.1.0-rc.6`。
|
|
78
|
+
|
|
79
|
+
### 方式二:从 GitHub 安装
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
dsh plugin --profile web add github:GengDaPeng/dsh-agent-message
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 方式三:直接发给你的 Agent
|
|
86
|
+
|
|
87
|
+
打开任意一个 DSH 会话,把下面这句话发给它:
|
|
88
|
+
|
|
89
|
+
> 帮我安装跨会话通信插件,执行:`dsh plugin --profile web add dsh-agent-message`
|
|
90
|
+
|
|
91
|
+
Agent 会用 bash 执行这条命令,装完自动挂载、所有会话立即可用。
|
|
92
|
+
|
|
93
|
+
### 装完自动发生了什么
|
|
94
|
+
|
|
95
|
+
插件自带 `cordis.patch.yml`(由 `package.json` 的 `dsh.bundle.patch` 指向),安装后自动把自己挂进宿主组合——所以你**不需要**手动改 preset、改 `cordis.patch.yml`。所有会话自动获得 `list_peer_agents`、`send_agent_message` 和 `check_delivery`。
|
|
96
|
+
|
|
97
|
+
## 使用
|
|
98
|
+
|
|
99
|
+
1. 对会话 A 说「列出可发送的其它 Agent」——它会调 `list_peer_agents`;
|
|
100
|
+
2. 记下目标会话的 `id`(或让对方点「复制ID」按钮);
|
|
101
|
+
3. 说「给 `<会话ID>` 发消息:……」——它会调 `send_agent_message`,目标在线立即送达、离线自动激活(失败则留言);
|
|
102
|
+
4. 会话 B 以 `user` 形态收到消息时可点击 `From Session · <名称>:`;`relay` 保持 Harness 上下文形态,并可通过原有的 `From session @<ID>` 来源行打开发送方会话。
|
|
103
|
+
5. (监督场景)说「查一下我发给 `<会话ID>` 的消息状态」——它会调 `check_delivery`。
|
|
104
|
+
|
|
105
|
+
## 原理
|
|
106
|
+
|
|
107
|
+
每个 Agent 都有一个收件箱 `Inbox`,里面是两条 FIFO 队列:
|
|
108
|
+
|
|
109
|
+
- `next-turn`:排队等待作为**独立一轮**处理的消息;
|
|
110
|
+
- `next-step`:当前轮次内、**下一步边界**消费的引导输入。
|
|
111
|
+
|
|
112
|
+
`send_agent_message` 的投递路径:
|
|
113
|
+
|
|
114
|
+
- **在线**:通过 `agents` 注册表找到目标 Agent,调 `steer()` / `followup()` / `inject()` 写入其收件箱;
|
|
115
|
+
- **离线留言(leave)**:把 `agent/inbox/spliced` 事件**持久化追加**进目标会话的日志——目标下次被打开(resume)时,收件箱会重放该事件,消息就在那里;
|
|
116
|
+
- **离线激活(wake)**:`agents.resume()` 恢复该会话(连同它记录的 agent preset),再 `followup()`,会话被唤醒并立即处理。
|
|
117
|
+
|
|
118
|
+
回执状态来自收件箱事件:消息还在队列里是 `delivered`,被对方某轮认领是 `claimed`,被取消是 `discarded`;目标是否正在运行通过独立的 `targetStatus` 返回,不把 Agent 的整体运行状态误当成某条消息正在处理。指定 `messageId` 时,`check_delivery` 会直接从目标现有 Inbox 日志恢复状态,因此进程重启后仍可查询。
|
|
119
|
+
|
|
120
|
+
`user` 消息的原始正文头包含发送者标题和完整会话 ID,界面只显示可导航的 `From Session · <名称>:` 消息头。`relay` 的正文不重复消息头,Harness 原生来源行显示为可导航的 `From session @<ID>`;两种形态的 `source` 元数据都保留发送者标题和纯 `session-...` ID。
|
|
121
|
+
|
|
122
|
+
## 目录结构
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
dsh-agent-message/
|
|
126
|
+
├── lib/
|
|
127
|
+
│ ├── index.js # host 半区:list_peer_agents / send_agent_message / check_delivery
|
|
128
|
+
│ └── client.js # client 半区:发送方会话导航与复制会话ID按钮
|
|
129
|
+
├── cordis.patch.yml # 自注册补丁(dsh.bundle.patch 指向它)
|
|
130
|
+
├── package.json # DSH 插件清单(dsh.bundle / dsh.client / dshx.contributes)
|
|
131
|
+
├── docs/ # 设计稿与 README 示例截图
|
|
132
|
+
├── README.md # 中文文档
|
|
133
|
+
└── README.en.md # English documentation
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## 限制
|
|
137
|
+
|
|
138
|
+
- 目标会话必须**未归档**且存在于本机持久化里;归档会话一律拒绝发送。
|
|
139
|
+
- 离线激活(wake)会把目标会话以**默认模型**恢复运行(不继承它上次手动切换的模型选择)。
|
|
140
|
+
- 不指定 `messageId` 的批量回执依赖内存记账,只覆盖本进程最近 1000 条发送记录(FIFO 淘汰);进程重启后仍可凭已知 `messageId` 查询,但不再返回易失的 `sentAt` 和 `mode`。
|
|
141
|
+
- 跨进程/跨机器通信不在本插件范围内。
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
[MIT](./LICENSE)
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# dsh-agent-message bundle patch:把插件挂进 profile 的宿主组合(自注册)。
|
|
2
|
+
# 由 package.json 的 dsh.bundle.patch 指向本文件,安装后自动挂载、全局可用。
|
|
3
|
+
#
|
|
4
|
+
# 可选配置:消息渲染形态 form —— user=对话式气泡、期待回复(默认);
|
|
5
|
+
# relay=上下文注入、静默影响代理行为、不期待回复。
|
|
6
|
+
# 需要切换时,在你的 profile 的 cordis.patch.yml 里覆盖此条目:
|
|
7
|
+
#
|
|
8
|
+
# - id: agent-message
|
|
9
|
+
# config:
|
|
10
|
+
# form: relay
|
|
11
|
+
- insert:
|
|
12
|
+
- id: agent-message
|
|
13
|
+
name: 'dsh-agent-message'
|
|
Binary file
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-agent-message",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
const React = require("react");
|
|
8
|
+
|
|
9
|
+
const name = "dsh-agent-message-client";
|
|
10
|
+
const inject = ["slots", "timer", "sessions"];
|
|
11
|
+
|
|
12
|
+
function apply(ctx) {
|
|
13
|
+
const senderSelector = '[data-ref-chip="subagent"], [data-context-relay-sender]';
|
|
14
|
+
|
|
15
|
+
function senderLink(target) {
|
|
16
|
+
if (!(target instanceof Element)) return null;
|
|
17
|
+
const element = target.closest(senderSelector);
|
|
18
|
+
if (!element) return null;
|
|
19
|
+
if (element.dataset.agentMsgSessionId) {
|
|
20
|
+
return { element: element, sessionId: element.dataset.agentMsgSessionId };
|
|
21
|
+
}
|
|
22
|
+
const match = String(element.textContent || "").match(/session-[\w-]+/);
|
|
23
|
+
return match ? { element: element, sessionId: match[0] } : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function titleFrom(text) {
|
|
27
|
+
const line = String(text || "").split("\n", 1)[0].trim();
|
|
28
|
+
const current = line.match(/^From (?:Session|Agent)(?: ·)? (.+?):(?: @session-[\w-]+)?$/);
|
|
29
|
+
const previous = line.match(/^来自 Agent · (.+)$/);
|
|
30
|
+
const legacy = line.match(/^来自 Agent「(.+)」\s*[·::]?$/);
|
|
31
|
+
return (current || previous || legacy)?.[1]?.trim() || "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function senderTitle(element) {
|
|
35
|
+
if (element.dataset.agentMsgSenderTitle) return element.dataset.agentMsgSenderTitle;
|
|
36
|
+
const label = element.previousElementSibling;
|
|
37
|
+
const title = titleFrom(label?.textContent);
|
|
38
|
+
if (title && label) {
|
|
39
|
+
label.textContent = "";
|
|
40
|
+
label.classList.add("agent-msg-sender-prefix");
|
|
41
|
+
}
|
|
42
|
+
return title;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function prepareSenderLinks(root) {
|
|
46
|
+
if (!(root instanceof Element)) return;
|
|
47
|
+
const elements = root.matches(senderSelector)
|
|
48
|
+
? [root].concat(Array.from(root.querySelectorAll(senderSelector)))
|
|
49
|
+
: Array.from(root.querySelectorAll(senderSelector));
|
|
50
|
+
elements.forEach(function (element) {
|
|
51
|
+
const link = senderLink(element);
|
|
52
|
+
if (!link) return;
|
|
53
|
+
const relay = element.matches("[data-context-relay-sender]");
|
|
54
|
+
const title = relay ? "" : senderTitle(element);
|
|
55
|
+
if (!relay && !title) return;
|
|
56
|
+
element.dataset.agentMsgSessionId = link.sessionId;
|
|
57
|
+
if (title) element.dataset.agentMsgSenderTitle = title;
|
|
58
|
+
element.textContent = relay ? "From session @" + link.sessionId : "From Session · " + title + ":";
|
|
59
|
+
element.classList.add("agent-msg-session-link");
|
|
60
|
+
element.setAttribute("role", "link");
|
|
61
|
+
element.setAttribute("tabindex", "0");
|
|
62
|
+
element.setAttribute("title", "打开发送方会话");
|
|
63
|
+
element.setAttribute("aria-label", "打开发送方会话:" + (title || link.sessionId));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function openSender(event) {
|
|
68
|
+
const element = event.target instanceof Element
|
|
69
|
+
? event.target.closest(".agent-msg-session-link")
|
|
70
|
+
: null;
|
|
71
|
+
const sessionId = element?.dataset.agentMsgSessionId;
|
|
72
|
+
if (!sessionId) return;
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
event.stopPropagation();
|
|
75
|
+
ctx.sessions.open(sessionId);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function onKeyDown(event) {
|
|
79
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
80
|
+
openSender(event);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function legacyCopyText(text) {
|
|
84
|
+
let ta;
|
|
85
|
+
try {
|
|
86
|
+
ta = document.createElement("textarea");
|
|
87
|
+
ta.value = text;
|
|
88
|
+
ta.setAttribute("readonly", "");
|
|
89
|
+
ta.style.position = "fixed";
|
|
90
|
+
ta.style.opacity = "0";
|
|
91
|
+
document.body.appendChild(ta);
|
|
92
|
+
ta.focus();
|
|
93
|
+
ta.select();
|
|
94
|
+
return document.execCommand("copy");
|
|
95
|
+
} catch (_) {
|
|
96
|
+
return false;
|
|
97
|
+
} finally {
|
|
98
|
+
ta?.remove();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function copyText(text) {
|
|
103
|
+
try {
|
|
104
|
+
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
|
105
|
+
await navigator.clipboard.writeText(text);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
} catch (_) {}
|
|
109
|
+
return legacyCopyText(text);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function CopyButton(props) {
|
|
113
|
+
const [copyState, setCopyState] = React.useState("idle");
|
|
114
|
+
async function onClick() {
|
|
115
|
+
const text = String(props.sessionId || "");
|
|
116
|
+
if (text === "") return;
|
|
117
|
+
setCopyState(await copyText(text) ? "copied" : "failed");
|
|
118
|
+
ctx.timeout(function () { setCopyState("idle"); }, 2000);
|
|
119
|
+
}
|
|
120
|
+
function onMouseLeave() { setCopyState("idle"); }
|
|
121
|
+
return React.createElement("button", {
|
|
122
|
+
type: "button",
|
|
123
|
+
onClick: onClick,
|
|
124
|
+
onMouseLeave: onMouseLeave,
|
|
125
|
+
title: "复制会话 ID",
|
|
126
|
+
"aria-label": "复制会话 ID",
|
|
127
|
+
className: "agent-msg-copy-id"
|
|
128
|
+
}, copyState === "copied" ? "已复制" : copyState === "failed" ? "复制失败" : "复制ID");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register(
|
|
132
|
+
{ name: "conversation.session.header.actions", id: "copy-session-id", order: 30, label: "复制会话ID" },
|
|
133
|
+
(props) => React.createElement(CopyButton, { sessionId: props.sessionId })
|
|
134
|
+
));
|
|
135
|
+
|
|
136
|
+
const css =
|
|
137
|
+
".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; } " +
|
|
138
|
+
".agent-msg-copy-id:hover { opacity: 1; border-color: rgba(127,127,127,.7); } " +
|
|
139
|
+
".agent-msg-sender-prefix { display: none !important; } " +
|
|
140
|
+
".agent-msg-session-link { cursor: pointer; text-decoration: none; } " +
|
|
141
|
+
".agent-msg-session-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; } " +
|
|
142
|
+
".agent-msg-session-link:hover { text-decoration: underline; text-underline-offset: 3px; } " +
|
|
143
|
+
".agent-msg-session-link:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; border-radius: 2px; }";
|
|
144
|
+
const tag = document.createElement("style");
|
|
145
|
+
tag.setAttribute("data-plugin", name);
|
|
146
|
+
tag.textContent = css;
|
|
147
|
+
document.head.appendChild(tag);
|
|
148
|
+
prepareSenderLinks(document.body);
|
|
149
|
+
const observer = new MutationObserver(function (records) {
|
|
150
|
+
records.forEach(function (record) {
|
|
151
|
+
record.addedNodes.forEach(prepareSenderLinks);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
155
|
+
document.addEventListener("click", openSender);
|
|
156
|
+
document.addEventListener("keydown", onKeyDown);
|
|
157
|
+
ctx.effect(() => () => {
|
|
158
|
+
observer.disconnect();
|
|
159
|
+
document.removeEventListener("click", openSender);
|
|
160
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
161
|
+
tag.remove();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
exports.name = name;
|
|
166
|
+
exports.inject = inject;
|
|
167
|
+
exports.apply = apply;
|
|
168
|
+
return module.exports;
|
|
169
|
+
}
|
|
170
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
|
|
3
|
+
export const name = 'dsh-agent-message'
|
|
4
|
+
export const inject = ['agents', 'tools']
|
|
5
|
+
|
|
6
|
+
export function apply(ctx, config) {
|
|
7
|
+
const agents = ctx.agents
|
|
8
|
+
/** 消息渲染形态:'user'=对话式气泡、期待回复(默认)|'relay'=上下文注入、静默影响代理行为、不期待回复。 */
|
|
9
|
+
const form = config?.form === 'relay' ? 'relay' : 'user'
|
|
10
|
+
let seq = 0
|
|
11
|
+
/** messageId -> { to, at, mode };发送成功即记账,供批量查询及补充本进程发送信息。 */
|
|
12
|
+
const sent = new Map()
|
|
13
|
+
/** 记账表 FIFO 上限:超过则淘汰最老记录,内存恒定。 */
|
|
14
|
+
const SENT_MAX = 1000
|
|
15
|
+
|
|
16
|
+
function rememberSent(messageId, to, mode) {
|
|
17
|
+
sent.set(messageId, { to, at: Date.now(), mode })
|
|
18
|
+
if (sent.size > SENT_MAX) sent.delete(sent.keys().next().value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mintId() {
|
|
22
|
+
seq += 1
|
|
23
|
+
return 'agent-msg-' + Date.now().toString(36) + '-' + seq + '-' + Math.random().toString(36).slice(2, 8)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeMessage(text, source) {
|
|
27
|
+
return { id: mintId(), role: 'user', content: [{ type: 'text', text }], source }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function titleOf(agent) {
|
|
31
|
+
const service = ctx.get('sessionTitle')
|
|
32
|
+
if (service !== undefined) {
|
|
33
|
+
try {
|
|
34
|
+
const snapshot = service.get(agent.session)
|
|
35
|
+
if (snapshot && typeof snapshot.title === 'string') return snapshot.title
|
|
36
|
+
} catch (_) {}
|
|
37
|
+
}
|
|
38
|
+
return ''
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 折叠日志里的 agent/inbox/spliced 事件,得到当前 inbox 状态 + 已认领/已丢弃集合。 */
|
|
42
|
+
function foldInbox(events) {
|
|
43
|
+
const state = { 'next-turn': [], 'next-step': [], claimed: new Set(), discarded: new Set() }
|
|
44
|
+
for (const ev of events) {
|
|
45
|
+
if (ev.type !== 'agent/inbox/spliced') continue
|
|
46
|
+
const s = ev.data
|
|
47
|
+
const list = state[s.target] ?? []
|
|
48
|
+
const removed = list.splice(s.start, s.removedCount ?? 0, ...s.inserted)
|
|
49
|
+
const bucket = s.outcome === 'canceled' ? state.discarded : state.claimed
|
|
50
|
+
for (const m of removed) bucket.add(m.id)
|
|
51
|
+
}
|
|
52
|
+
return state
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function archivedIds() {
|
|
56
|
+
const workspace = ctx.get('workspaceRegistry')
|
|
57
|
+
return new Set((workspace !== undefined ? workspace.archivedSessionIds : []).map((id) => String(id)))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 离线留言:把消息持久化写进目标会话的 inbox(next-turn 末尾),不唤醒。
|
|
61
|
+
* 写入前双检目标仍未上线;若恰好上线,抛 'target went live',由调用方转在线投递。 */
|
|
62
|
+
async function leaveOffline(id, message) {
|
|
63
|
+
const persistence = ctx.get('sessionPersistence')
|
|
64
|
+
if (persistence === undefined) throw new Error('本部署无会话持久化,无法离线留言')
|
|
65
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
66
|
+
if (agents.get(id) !== undefined) throw new Error('target went live')
|
|
67
|
+
const { meta, events } = await persistence.readFrom(id, 0)
|
|
68
|
+
if (agents.get(id) !== undefined) throw new Error('target went live')
|
|
69
|
+
const state = foldInbox(events.slice(meta.seedLength ?? 0))
|
|
70
|
+
const start = state['next-turn'].length
|
|
71
|
+
const nextSeq = events.length === 0 ? 0 : events[events.length - 1].seq + 1
|
|
72
|
+
try {
|
|
73
|
+
await persistence.append(id, [{
|
|
74
|
+
seq: nextSeq,
|
|
75
|
+
type: 'agent/inbox/spliced',
|
|
76
|
+
time: Date.now(),
|
|
77
|
+
data: { target: 'next-turn', start, inserted: [message] },
|
|
78
|
+
}])
|
|
79
|
+
return
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (attempt === 0 && error instanceof Error && error.message.includes('append seq mismatch')) continue
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 留言投递:写入失败时若目标恰好上线,自动转在线投递(模型无感,不报错)。 */
|
|
88
|
+
async function deliverLeave(id, message) {
|
|
89
|
+
try {
|
|
90
|
+
await leaveOffline(id, message)
|
|
91
|
+
return { usedMode: 'leave', fallback: '' }
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const nowLive = agents.get(id)
|
|
94
|
+
if (nowLive !== undefined) {
|
|
95
|
+
nowLive.followup(message)
|
|
96
|
+
return { usedMode: 'followup', fallback: '留言写入时目标恰好上线,已改为在线投递' }
|
|
97
|
+
}
|
|
98
|
+
throw error
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 离线激活:resume 该会话(恢复其记录的 preset + 默认模型)并 followup。 */
|
|
103
|
+
async function wakeOffline(id, message) {
|
|
104
|
+
const persistence = ctx.get('sessionPersistence')
|
|
105
|
+
if (persistence === undefined) throw new Error('本部署无会话持久化,无法激活离线会话')
|
|
106
|
+
const inspected = await persistence.inspect(id)
|
|
107
|
+
let presetId = inspected.meta !== undefined ? inspected.meta.agentPreset : undefined
|
|
108
|
+
const events = inspected.events ?? []
|
|
109
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
110
|
+
const ev = events[i]
|
|
111
|
+
if (ev && ev.type === 'agent-preset/selected') {
|
|
112
|
+
presetId = ev.data.agentPreset
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const presets = ctx.get('agentPresets')
|
|
117
|
+
const defaultModel = ctx.get('agentDefaultModel')
|
|
118
|
+
const selection = defaultModel !== undefined ? defaultModel.currentSelection() : { provider: '', model: '' }
|
|
119
|
+
const handle = await agents.resume({
|
|
120
|
+
resumeSessionId: id,
|
|
121
|
+
agentOptions: { provider: selection.provider ?? '', model: selection.model ?? '' },
|
|
122
|
+
...(presets !== undefined && presetId !== undefined
|
|
123
|
+
? { setup: async (agentCtx) => { await presets.mount(agentCtx, presetId) } }
|
|
124
|
+
: {}),
|
|
125
|
+
})
|
|
126
|
+
handle.agent.followup(message)
|
|
127
|
+
return handle.agent
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 一次读取并折叠目标会话,供同一批回执查询复用。 */
|
|
131
|
+
async function deliverySnapshotOf(to) {
|
|
132
|
+
const target = agents.get(to)
|
|
133
|
+
if (target !== undefined) {
|
|
134
|
+
const state = foldInbox(target.session.events.slice(target.session.header.seedLength ?? 0))
|
|
135
|
+
return {
|
|
136
|
+
pending: new Set(target.inbox.nextTurn.concat(target.inbox.nextStep).map((message) => message.id)),
|
|
137
|
+
claimed: state.claimed,
|
|
138
|
+
discarded: state.discarded,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const persistence = ctx.get('sessionPersistence')
|
|
142
|
+
if (persistence === undefined) return { pending: new Set(), claimed: new Set(), discarded: new Set() }
|
|
143
|
+
const { meta, events } = await persistence.readFrom(to, 0)
|
|
144
|
+
const state = foldInbox(events.slice(meta.seedLength ?? 0))
|
|
145
|
+
return {
|
|
146
|
+
pending: new Set(state['next-turn'].concat(state['next-step']).map((message) => message.id)),
|
|
147
|
+
claimed: state.claimed,
|
|
148
|
+
discarded: state.discarded,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 回执状态:delivered 排队中 / claimed 已认领 / discarded 被丢弃 / unknown 查无此消息。 */
|
|
153
|
+
function deliveryStateOf(messageId, snapshot) {
|
|
154
|
+
if (snapshot.pending.has(messageId)) return 'delivered'
|
|
155
|
+
if (snapshot.discarded.has(messageId)) return 'discarded'
|
|
156
|
+
if (snapshot.claimed.has(messageId)) return 'claimed'
|
|
157
|
+
return 'unknown'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 会话恢复(用户打开)时,若有排队中的留言,唤醒它开始处理——留言"打开即达"。 */
|
|
161
|
+
ctx.on('agent/session-start', ({ agent }) => {
|
|
162
|
+
if (agent !== undefined && agent.inbox !== undefined
|
|
163
|
+
&& agent.inbox.nextTurn.some((message) => message.source?.plugin === name)) {
|
|
164
|
+
agent.wakeDriver()
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
ctx.tools.register(defineTool({
|
|
169
|
+
name: 'list_peer_agents',
|
|
170
|
+
description:
|
|
171
|
+
'列出当前进程里所有可发送(未归档)的 DeepSeek Harness Agent/会话,用于跨会话通信。' +
|
|
172
|
+
'每条含:id(会话 ID)、标题、工作目录、status(offline=进程里未加载、重启后未打开;其余为在线)、' +
|
|
173
|
+
'kind(peer=平级会话 / subagent=子代理)。在线在前、按标题排序。' +
|
|
174
|
+
'找到目标会话后,用它的 id 调用 send_agent_message 发送消息(目标离线也会自动激活或留言)。' +
|
|
175
|
+
'注意:它不同于 list_agents(后者列的是你的后台子代理)。',
|
|
176
|
+
parameters: {},
|
|
177
|
+
output: {
|
|
178
|
+
schema: { type: 'json' },
|
|
179
|
+
render(_args, value) {
|
|
180
|
+
return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
async execute(_args, exec) {
|
|
184
|
+
const me = exec.agent
|
|
185
|
+
const archived = archivedIds()
|
|
186
|
+
const persistence = ctx.get('sessionPersistence')
|
|
187
|
+
const live = new Map()
|
|
188
|
+
for (const agent of agents.list()) live.set(String(agent.id), agent)
|
|
189
|
+
|
|
190
|
+
const ids = new Set()
|
|
191
|
+
for (const id of live.keys()) ids.add(id)
|
|
192
|
+
const headers = persistence !== undefined ? await persistence.list() : []
|
|
193
|
+
const headerMap = new Map(headers.map((header) => [String(header.id), header]))
|
|
194
|
+
for (const id of headerMap.keys()) ids.add(id)
|
|
195
|
+
|
|
196
|
+
const rows = []
|
|
197
|
+
for (const id of ids) {
|
|
198
|
+
if (archived.has(id)) continue
|
|
199
|
+
const agent = live.get(id)
|
|
200
|
+
const header = headerMap.get(id)
|
|
201
|
+
const status = agent !== undefined ? agent.status : 'offline'
|
|
202
|
+
const cwd = (agent !== undefined && agent.session !== undefined && agent.session.header !== undefined ? agent.session.header.cwd : '')
|
|
203
|
+
|| (header !== undefined ? header.cwd : '') || ''
|
|
204
|
+
const kind = ((header !== undefined && header.parentSession !== undefined)
|
|
205
|
+
|| (agent !== undefined && agent.session !== undefined && agent.session.header !== undefined && agent.session.header.parentSession !== undefined))
|
|
206
|
+
? 'subagent' : 'peer'
|
|
207
|
+
rows.push({ id, title: '', cwd, status, kind, self: me !== undefined && String(me.id) === id })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const query = ctx.get('sessionQuery')
|
|
211
|
+
if (query !== undefined) {
|
|
212
|
+
const snapshots = await query.readTitleSnapshots(rows.map((r) => r.id))
|
|
213
|
+
const titleMap = new Map()
|
|
214
|
+
for (const t of snapshots) {
|
|
215
|
+
if (t.status === 'fulfilled' && t.value !== undefined && t.value.title !== undefined && typeof t.value.title.title === 'string') {
|
|
216
|
+
titleMap.set(String(t.sessionId), t.value.title.title)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const row of rows) row.title = titleMap.get(row.id) ?? ''
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const rank = (row) => (row.status === 'running' ? 0 : row.status === 'idle' ? 1 : 2)
|
|
223
|
+
rows.sort((a, b) => {
|
|
224
|
+
const ra = rank(a)
|
|
225
|
+
const rb = rank(b)
|
|
226
|
+
if (ra !== rb) return ra - rb
|
|
227
|
+
const ta = a.title || '~'
|
|
228
|
+
const tb = b.title || '~'
|
|
229
|
+
return ta < tb ? -1 : ta > tb ? 1 : 0
|
|
230
|
+
})
|
|
231
|
+
for (const row of rows) if (row.title === '') row.title = '(无标题 · ' + String(row.id).replace(/^session-/, '').slice(0, 8) + ')'
|
|
232
|
+
return rows
|
|
233
|
+
},
|
|
234
|
+
}))
|
|
235
|
+
|
|
236
|
+
ctx.tools.register(defineTool({
|
|
237
|
+
name: 'send_agent_message',
|
|
238
|
+
description:
|
|
239
|
+
'向另一个 Agent/会话发送消息(跨会话通信)。默认立即送达:目标在线则引导其当前工作(steer);' +
|
|
240
|
+
'目标离线(进程里未加载)则自动激活该会话并投递(wake),激活失败自动改为留言(leave)。' +
|
|
241
|
+
'mode 可选:steer/followup/inject(仅在线)、leave(留言不唤醒)、wake(激活)。归档会话一律拒绝。' +
|
|
242
|
+
'注意:它不同于 send_message(后者是给你的后台子代理续聊)。',
|
|
243
|
+
parameters: {
|
|
244
|
+
to: { type: 'string', required: true, description: '目标会话/Agent ID,来自 list_peer_agents 或复制到的会话 ID。' },
|
|
245
|
+
content: { type: 'string', required: true, description: '要发送的消息文本。' },
|
|
246
|
+
mode: { type: 'string', enum: ['steer', 'followup', 'inject', 'leave', 'wake'], description: 'steer/followup/inject 仅在线;leave 留言不唤醒;wake 激活。不传则智能默认。' },
|
|
247
|
+
},
|
|
248
|
+
output: {
|
|
249
|
+
schema: { type: 'json' },
|
|
250
|
+
render(_args, value) {
|
|
251
|
+
return [{ type: 'text', text: value.text || JSON.stringify(value) }]
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
async execute(args, exec) {
|
|
255
|
+
const me = exec.agent
|
|
256
|
+
if (me === undefined) throw new Error('no calling agent')
|
|
257
|
+
const to = args.to
|
|
258
|
+
if (to === '' || String(to) === String(me.id)) throw new Error('不能给自己发消息')
|
|
259
|
+
|
|
260
|
+
const archived = archivedIds()
|
|
261
|
+
if (archived.has(String(to))) throw new Error('对方会话已归档,无法发送(请先取消归档)')
|
|
262
|
+
|
|
263
|
+
const myTitle = titleOf(me) || String(me.id)
|
|
264
|
+
const source = {
|
|
265
|
+
kind: form === 'relay' ? 'plugin' : 'user',
|
|
266
|
+
plugin: name,
|
|
267
|
+
form,
|
|
268
|
+
senderSessionId: String(me.id),
|
|
269
|
+
senderTitle: myTitle,
|
|
270
|
+
}
|
|
271
|
+
const message = makeMessage(form === 'relay' ? args.content : 'From Session · ' + myTitle + ': @' + String(me.id) + '\n' + args.content, source)
|
|
272
|
+
|
|
273
|
+
const target = agents.get(to)
|
|
274
|
+
let usedMode = ''
|
|
275
|
+
let fallback = ''
|
|
276
|
+
|
|
277
|
+
if (target !== undefined) {
|
|
278
|
+
const mode = args.mode ?? 'steer'
|
|
279
|
+
if (mode === 'followup' || mode === 'wake') { target.followup(message); usedMode = mode }
|
|
280
|
+
else if (mode === 'leave') { target.inbox.append('next-turn', message); usedMode = 'leave' }
|
|
281
|
+
else if (mode === 'inject') { target.inject(message); usedMode = 'inject' }
|
|
282
|
+
else { target.steer(message); usedMode = 'steer' }
|
|
283
|
+
} else {
|
|
284
|
+
const persistence = ctx.get('sessionPersistence')
|
|
285
|
+
const headers = persistence !== undefined ? await persistence.list() : []
|
|
286
|
+
const header = headers.find((h) => String(h.id) === String(to))
|
|
287
|
+
if (header === undefined) throw new Error('会话不存在:' + to)
|
|
288
|
+
const mode = args.mode ?? 'wake'
|
|
289
|
+
if (mode === 'steer' || mode === 'followup' || mode === 'inject') {
|
|
290
|
+
throw new Error('目标离线(进程里未加载):' + mode + ' 仅用于在线会话;不传 mode(自动激活)或用 wake/leave')
|
|
291
|
+
}
|
|
292
|
+
if (mode === 'wake') {
|
|
293
|
+
try {
|
|
294
|
+
await wakeOffline(to, message)
|
|
295
|
+
usedMode = 'wake'
|
|
296
|
+
} catch (error) {
|
|
297
|
+
try {
|
|
298
|
+
const result = await deliverLeave(to, message)
|
|
299
|
+
usedMode = result.usedMode
|
|
300
|
+
fallback = '激活失败:' + (error instanceof Error ? error.message : String(error))
|
|
301
|
+
+ (result.fallback !== '' ? ';' + result.fallback : '(已改为留言)')
|
|
302
|
+
} catch (leaveError) {
|
|
303
|
+
throw new Error('激活失败(' + (error instanceof Error ? error.message : String(error))
|
|
304
|
+
+ '),且留言也失败(' + (leaveError instanceof Error ? leaveError.message : String(leaveError)) + ')')
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
const result = await deliverLeave(to, message)
|
|
309
|
+
usedMode = result.usedMode
|
|
310
|
+
fallback = result.fallback
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
rememberSent(message.id, String(to), usedMode)
|
|
315
|
+
return {
|
|
316
|
+
ok: true,
|
|
317
|
+
to: String(to),
|
|
318
|
+
mode: usedMode,
|
|
319
|
+
messageId: message.id,
|
|
320
|
+
...(fallback !== '' ? { fallback } : {}),
|
|
321
|
+
text: fallback !== '' ? fallback : '已通过 ' + usedMode + ' 模式向 Agent ' + String(to) + ' 发送消息。',
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
}))
|
|
325
|
+
|
|
326
|
+
ctx.tools.register(defineTool({
|
|
327
|
+
name: 'check_delivery',
|
|
328
|
+
description:
|
|
329
|
+
'按需查询发给某会话的消息状态(跨会话回执,默认安静——只有监督场景主动调用时才返回,不做任何自动播报)。' +
|
|
330
|
+
'状态:delivered=已送达·排队中;claimed=已被对方认领;discarded=被丢弃;unknown=查无此消息。' +
|
|
331
|
+
'传 messageId 时可在进程重启后从目标 Inbox 日志恢复状态;不传则只返回本进程内发给该会话的全部已记账消息。',
|
|
332
|
+
parameters: {
|
|
333
|
+
to: { type: 'string', required: true, description: '目标会话 ID。' },
|
|
334
|
+
messageId: { type: 'string', description: '可选:只查这一条消息(来自 send_agent_message 的返回值)。' },
|
|
335
|
+
},
|
|
336
|
+
output: {
|
|
337
|
+
schema: { type: 'json' },
|
|
338
|
+
render(_args, value) {
|
|
339
|
+
return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
async execute(args) {
|
|
343
|
+
const target = agents.get(args.to)
|
|
344
|
+
const targetStatus = target !== undefined ? target.status : 'offline'
|
|
345
|
+
if (args.messageId !== undefined) {
|
|
346
|
+
const entry = sent.get(args.messageId)
|
|
347
|
+
if (entry !== undefined && String(entry.to) !== String(args.to)) {
|
|
348
|
+
return { to: args.to, entries: [{ messageId: args.messageId, state: 'unknown', targetStatus }] }
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const wanted = args.messageId !== undefined
|
|
352
|
+
? [args.messageId]
|
|
353
|
+
: [...sent.entries()].filter(([, e]) => String(e.to) === String(args.to)).map(([id]) => id)
|
|
354
|
+
const entries = []
|
|
355
|
+
const snapshot = wanted.length > 0 ? await deliverySnapshotOf(args.to) : undefined
|
|
356
|
+
for (const messageId of wanted) {
|
|
357
|
+
const entry = sent.get(messageId)
|
|
358
|
+
if (entry !== undefined && String(entry.to) !== String(args.to)) continue
|
|
359
|
+
const state = deliveryStateOf(messageId, snapshot)
|
|
360
|
+
entries.push({
|
|
361
|
+
messageId,
|
|
362
|
+
...(entry !== undefined ? { sentAt: entry.at, mode: entry.mode } : {}),
|
|
363
|
+
state,
|
|
364
|
+
targetStatus,
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
return { to: args.to, entries }
|
|
368
|
+
},
|
|
369
|
+
}))
|
|
370
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-agent-message",
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"packageManager": "pnpm@11.1.1",
|
|
5
|
+
"description": "跨会话 Agent 通信:让 DeepSeek Harness 里不同的 Agent 会话互相收发消息。",
|
|
6
|
+
"keywords": ["deepseek", "deepseek-harness", "agent", "multi-agent", "messaging"],
|
|
7
|
+
"homepage": "https://github.com/GengDaPeng/dsh-agent-message#readme",
|
|
8
|
+
"bugs": { "url": "https://github.com/GengDaPeng/dsh-agent-message/issues" },
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/GengDaPeng/dsh-agent-message.git"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./lib/index.js",
|
|
15
|
+
"scripts": { "test": "node --test" },
|
|
16
|
+
"exports": {
|
|
17
|
+
".": { "default": "./lib/index.js" },
|
|
18
|
+
"./client": { "default": "./lib/client.js" },
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": ["lib", "cordis.patch.yml", "docs/assets/message-header-navigation.jpg"],
|
|
22
|
+
"publishConfig": { "access": "public" },
|
|
23
|
+
"dsh": {
|
|
24
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
25
|
+
"client": {
|
|
26
|
+
"platform": "web",
|
|
27
|
+
"inject": [
|
|
28
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
29
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"dshx": {
|
|
35
|
+
"contributes": {
|
|
36
|
+
"tools": ["list_peer_agents", "send_agent_message", "check_delivery"],
|
|
37
|
+
"skills": []
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
42
|
+
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.6 <0.2.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@deepseek-ai/dsh-tools": "0.1.0-rc.6"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=24 <25",
|
|
49
|
+
"dsh": ">=0.1.0-rc.6 <0.2.0"
|
|
50
|
+
},
|
|
51
|
+
"license": "MIT"
|
|
52
|
+
}
|