dsh-agent-message 1.4.0 → 1.5.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
@@ -13,7 +13,7 @@ English | [中文](./README.md)
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
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;
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;
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.
@@ -22,47 +22,29 @@ Typical scenarios: an orchestrator Agent dispatching work to a developer Agent,
22
22
 
23
23
  | Capability | Description |
24
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 |
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
+ | `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
+ | `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 |
29
30
  | Copy session id | A "Copy ID" button is added to the session header for one-click copying of the current session id |
30
31
 
31
32
  ### Sender navigation example
32
33
 
33
34
  ![Clickable sender header example](./docs/assets/message-header-navigation.jpg)
34
35
 
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
+ The image shows navigation on a historical `user` bubble. Current relay messages use the same visible-card and sender-navigation experience while retaining plugin `relay` provenance instead of impersonating human input. The full session id remains in typed source metadata and in a Host-generated model-visible protocol header, so the receiving Agent never has to guess the sender.
36
37
 
37
38
  ### Delivery modes (the `mode` parameter of `send_agent_message`)
38
39
 
39
40
  | mode | Meaning |
40
41
  |---|---|
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 |
42
+ | (default, omitted) | `followup`: create an independent turn; an offline target is resumed automatically before delivery |
43
+ | `followup` | Same as the default; queue directly when online, or resume and queue when offline |
44
+ | `steer` | Intervene in the target's current work immediately (`running` sessions only) |
45
+ | `inject` | Add next-step context without interrupting the current goal (`running` sessions only) |
47
46
 
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
- ```
47
+ **Archived sessions and actual subagents are always rejected**; ordinary forks remain independent and sendable. Sending to yourself is also rejected.
66
48
 
67
49
  ## Installation
68
50
 
@@ -95,11 +77,12 @@ The plugin ships a `cordis.patch.yml` (pointed to by `dsh.bundle.patch` in `pack
95
77
 
96
78
  ## Usage
97
79
 
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`.
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;
83
+ 4. You can still ask the Agent to call `list_peer_agents` and send directly with a full session id;
84
+ 5. Session B receives a native `UserMessage` with a typed relay source plus a minimal Host-generated source header on the first body line, so B does not have to guess the sender. The Client presents it as a visible Agent message card whose header opens the sender session;
85
+ 6. (Supervision) Say "check the status of my messages to `<session id>`" — it calls `check_delivery`.
103
86
 
104
87
  ## How it works
105
88
 
@@ -110,13 +93,19 @@ Each Agent has an inbox `Inbox` containing two FIFO queues:
110
93
 
111
94
  Delivery paths of `send_agent_message`:
112
95
 
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.
96
+ - **Ordinary online message**: find the target Agent through the `agents` registry and call `followup()` so it enters an independent `next-turn`;
97
+ - **Running-mode semantics**: users do not need to name a mode. The Agent selects `steer()` when the full request clearly asks for immediate intervention, or `inject()` when it clearly asks to add context without interrupting the current task. The target must actually be `running`; when intent is unclear, the Agent keeps the default `followup()`;
98
+ - **Ordinary offline message**: first read and validate one logical-session snapshot through `sessionQuery.readSession()`, then restore it through the public `agents.resume()` API and call `followup()`. The plugin retains and reuses the returned handle, keeps the target loaded after it becomes idle, and releases the handle only when the plugin unloads. Resume failures are returned directly; the plugin does not forge core Inbox events as a fallback note.
116
99
 
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.
100
+ Session enumeration, batched titles, and offline log reads use Harness's `sessionQuery.listSessions()`, `readTitleSnapshots()`, and `readSession()` respectively. `SessionId` is the only address; `parentSession` records fork lineage only, and only `origin: subagent` identifies an actual subagent. The plugin does not scan `sessionPersistence` directly to rebuild a parallel session directory.
118
101
 
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.
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
+
104
+ Every cross-session message is created by Harness `createUserMessage()`, and `UserMessage.id` is its only message identity. Its source always uses `kind: dsh-agent-message` and `form: relay`, plus the protocol version, sender/target Session ids, and display title. Because current Harness model requests do not expand custom source fields, the Host also writes a minimal `<dsh-agent-message>` header containing only `senderSessionId` on the first body line. The typed source is the durable/UI truth; the header is only the model-visible projection needed for reply addressing. The plugin registers no global system prompt; send admission lives only in the `send_agent_message` tool contract. The Client only projects relay as a visible Agent message card and never rewrites an Agent message as human `user` provenance.
105
+
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
+
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.
120
109
 
121
110
  ## Directory structure
122
111
 
@@ -124,7 +113,7 @@ For `user` messages, the raw body header contains the sender title and full sess
124
113
  dsh-agent-message/
125
114
  ├── lib/
126
115
  │ ├── 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
116
+ │ └── client.js # client half: @session references, session navigation and copy-session-id button
128
117
  ├── cordis.patch.yml # self-registration patch (pointed to by dsh.bundle.patch)
129
118
  ├── package.json # DSH plugin manifest (dsh.bundle / dsh.client / dshx.contributes)
130
119
  ├── docs/ # design notes and README example screenshot
@@ -132,10 +121,14 @@ dsh-agent-message/
132
121
  └── README.en.md # English documentation
133
122
  ```
134
123
 
124
+ ## In Development
125
+
126
+ - **Cross-process communication:** Allow Agent sessions running in different DSH processes to exchange messages.
127
+
135
128
  ## Limitations
136
129
 
137
130
  - 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).
131
+ - Automatically resuming an offline session uses the **default model** (it does not inherit a model manually selected earlier in that session); if resume fails, the message is not written to the target Inbox.
139
132
  - Bulk receipt queries without `messageId` rely on in-memory bookkeeping and cover only the most recent 1000 sends in the current process (FIFO eviction). After a restart, a known `messageId` remains queryable, but the volatile `sentAt` and `mode` fields are no longer returned.
140
133
  - Cross-process / cross-machine communication is out of scope.
141
134
 
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  在 DeepSeek Harness 里,一个进程会同时挂着多个 Agent 会话。本插件给每个会话装上三个工具,让它们能互相"发消息":
14
14
 
15
15
  - 发消息前,先**列出所有可发送的会话**(未归档的都在列,含离线未打开的),按标题找到目标;
16
- - 找到后,**把消息投递到目标会话**——目标在线就立即引导它当前的工作;目标离线(进程重启后还没打开)就**自动把它激活**再投递,激活失败则自动转为**留言**(下次打开可见);
16
+ - 找到后,**把消息投递到目标会话**——普通消息统一进入独立的新 turn;目标离线(进程重启后还没打开)时,插件通过 Harness 公开接口恢复会话、投递,并在处理结束后释放 runtime;
17
17
  - 需要时,可以**按需查询**某条消息的送达状态(排队中/已认领/被丢弃/未知),并单独查看目标是否正在运行,供监督场景使用。
18
18
 
19
19
  典型场景:编排者 Agent 给开发 Agent 派活、两个 Agent 协作接力、主会话给测试会话发指令、监督者 Agent 盯梢多个 worker。
@@ -22,47 +22,29 @@
22
22
 
23
23
  | 能力 | 说明 |
24
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>` 来源行均可点击或通过键盘打开发送方会话 |
25
+ | `list_peer_agents` | 列出所有**可发送**的独立会话:未归档、排除真实子代理;普通 fork 保留。返回 id、标题、工作目录和运行状态 |
26
+ | `send_agent_message` | 给指定会话 ID 发消息;默认使用 `followup` 创建独立的新 turn,离线时自动恢复后投递;显式支持 `followup`、`inject`、`steer` |
27
+ | `check_delivery` | 按需查询消息回执(pending/claimed/discarded/unknown);发送成功时先返回 accepted,接纳前失败由工具错误表示;指定消息 ID 时支持重启后恢复查询 |
28
+ | `@` 会话定位 | 在输入框开头键入 `@` 选择目标;候选只显示会话标题和“运行中/空闲”,空白占位和子代理不混入。选择后用户看到可读标题,当前 Agent 收到稳定 session ID;`@` 只定位,发送、读取或分析由整句意图决定 |
29
+ | 可见的 Agent 消息卡片 | relay 仍保留真实的插件来源,但 Client 将它显示为左侧 Agent 消息卡片;`From Session · <名称>:` 可点击或通过键盘打开发送方会话 |
29
30
  | 复制会话 ID | 会话头部新增「复制ID」按钮,一键复制当前会话 ID |
30
31
 
31
- ### 消息头跳转示例
32
+ ### 发送方导航示例
32
33
 
33
34
  ![可点击的发送者消息头示例](./docs/assets/message-header-navigation.jpg)
34
35
 
35
- 用户只看到发送 Agent 的名称;点击整行即可打开发送方会话。完整会话 ID 仍保留在原始消息和元数据中,供接收 Agent 准确识别与回复。
36
+ 图中是历史 `user` 气泡的导航效果;当前 relay 消息使用相同的可见卡片和发送方跳转,但持久化来源仍是插件 `relay`,不会伪装成人类输入。完整会话 ID 同时保留在 typed source 和 Host 生成的模型可见协议头中,避免接收 Agent 猜测发送方。
36
37
 
37
38
  ### 投递模式(`send_agent_message` 的 `mode` 参数)
38
39
 
39
40
  | mode | 含义 |
40
41
  |---|---|
41
- | (默认,不传) | **立即送达**:在线 → `steer`;离线 `wake`(激活失败自动转 `leave`) |
42
- | `steer` | 引导对方当前的工作(仅在线) |
43
- | `followup` | 给对方排一条新的独立轮次(仅在线) |
44
- | `inject` | 静默注入下一步,但不唤醒(仅在线) |
45
- | `leave` | 留言:写进对方收件箱但不唤醒;离线会话就是"留言板" |
46
- | `wake` | 激活:离线会话先 `resume` 再投递;在线等价于 `followup` |
42
+ | (默认,不传) | `followup`:给目标创建独立的新 turn;离线时自动 `resume` 后投递 |
43
+ | `followup` | 与默认相同;在线直接排队,离线自动恢复后排队 |
44
+ | `steer` | 立即介入对方当前工作(仅 `running` 会话) |
45
+ | `inject` | 不打断当前目标,静默补充下一步上下文(仅 `running` 会话) |
47
46
 
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
- ```
47
+ **归档会话和真实子代理一律拒绝发送**;普通 fork 仍是独立会话,可以发送。发给自己也会被拒绝。
66
48
 
67
49
  ## 安装
68
50
 
@@ -96,11 +78,12 @@ Agent 会用 bash 执行这条命令,装完自动挂载、所有会话立即
96
78
 
97
79
  ## 使用
98
80
 
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`。
81
+ 1. 在会话 A 的输入框开头键入 `@`,从原生候选菜单中选择目标会话;候选会显示标题和“运行中/空闲”;
82
+ 2. `@` 只告诉 A 信息或操作的目标在哪里,不代表发送。当前请求或用户已授予的编排职责要求跨会话传递信息时,A 调用 `send_agent_message`并静默执行路由决策。例如 `@B 告诉他最后提交 PR draft 就停止` 会发送,`@B 帮我分析他最新的对话结果` 则只按需读取 B,不向用户解释内部路由判断;
83
+ 3. 显式要求转告时,A 只负责投递和报告结果,不代为执行被转发的任务,也不要求 B 额外回复“收到”;如果正文明确要求 B 把业务结果返回 A,B 才向 `senderSessionId` 发送结果;
84
+ 4. 也可以让 Agent `list_peer_agents`,再用完整会话 ID 直接发送;
85
+ 5. 会话 B 收到的是带 typed relay source 的原生 `UserMessage`;正文首行还有 Host 生成的最小来源协议,B 不需要猜测发送方;Client 将其显示为可见 Agent 消息卡片,并可从消息头打开发送方会话;
86
+ 6. (监督场景)说「查一下我发给 `<会话ID>` 的消息状态」——它会调 `check_delivery`。
104
87
 
105
88
  ## 原理
106
89
 
@@ -111,13 +94,19 @@ Agent 会用 bash 执行这条命令,装完自动挂载、所有会话立即
111
94
 
112
95
  `send_agent_message` 的投递路径:
113
96
 
114
- - **在线**:通过 `agents` 注册表找到目标 Agent,调 `steer()` / `followup()` / `inject()` 写入其收件箱;
115
- - **离线留言(leave)**:把 `agent/inbox/spliced` 事件**持久化追加**进目标会话的日志——目标下次被打开(resume)时,收件箱会重放该事件,消息就在那里;
116
- - **离线激活(wake)**:`agents.resume()` 恢复该会话(连同它记录的 agent preset),再 `followup()`,会话被唤醒并立即处理。
97
+ - **在线普通消息**:通过 `agents` 注册表找到目标 Agent,调用 `followup()` 进入独立的 `next-turn`;
98
+ - **运行中高级语义**:用户无需说出模式名;Agent 根据整句话判断,明确要求立即介入时使用 `steer()`,明确要求不打断当前任务、只补充上下文时使用 `inject()`;目标必须确实为 `running`,判断不清时仍使用默认 `followup()`;
99
+ - **离线普通消息**:先由 `sessionQuery.readSession()` 读取同一份逻辑会话快照并校验目标,再通过公开 `agents.resume()` 恢复、调用 `followup()`;插件持有并复用恢复得到的 handle,目标回到 idle 后仍保持加载,只在插件卸载时释放。恢复失败直接返回失败,不伪造核心 Inbox 事件作为留言。
117
100
 
118
- 回执状态来自收件箱事件:消息还在队列里是 `delivered`,被对方某轮认领是 `claimed`,被取消是 `discarded`;目标是否正在运行通过独立的 `targetStatus` 返回,不把 Agent 的整体运行状态误当成某条消息正在处理。指定 `messageId` 时,`check_delivery` 会直接从目标现有 Inbox 日志恢复状态,因此进程重启后仍可查询。
101
+ 会话枚举、批量标题和离线日志读取分别使用 Harness `sessionQuery.listSessions()`、`readTitleSnapshots()` `readSession()`。`SessionId` 是唯一地址;`parentSession` 只记录分叉血缘,只有 `origin: subagent` 才会被识别为真实子代理。插件不直接扫描 `sessionPersistence` 重建另一份会话目录。
119
102
 
120
- `user` 消息的原始正文头包含发送者标题和完整会话 ID,界面只显示可导航的 `From Session · <名称>:` 消息头。`relay` 的正文不重复消息头,Harness 原生来源行显示为可导航的 `From session @<ID>`;两种形态的 `source` 元数据都保留发送者标题和纯 `session-...` ID。
103
+ `send_agent_message` 成功把原生消息提交给目标 Inbox 后立即返回 `accepted` 和该消息的原生 `messageId`;`check_delivery` 根据 Inbox 事件返回 `pending`(仍在排队)、`claimed`(已被某轮认领)、`discarded`(被取消)或 `unknown`。`claimed` 只是传输证据,不表示已读、回复或任务完成。接纳前失败由 Harness 工具错误表示,不写入目标 Inbox。目标是否正在运行通过独立的 `targetRuntimeStatus` 返回,不把 Agent 的整体运行状态误当成某条消息正在处理。指定 `messageId` 时可从目标现有 Inbox 日志恢复状态,因此进程重启后仍可查询。
104
+
105
+ 所有跨会话消息都由 Harness `createUserMessage()` 创建,`UserMessage.id` 是唯一消息身份。`source.kind` 固定为 `dsh-agent-message`,`form` 固定为 `relay`,并携带协议版本、发送/目标 Session 和显示标题。由于当前 Harness 不会把自定义 source 字段展开给模型,Host 还会在正文首行写入只含 `senderSessionId` 的最小 `<dsh-agent-message>` 协议头;source 是持久化/UI 真相,协议头只是回复寻址所需的模型可见投影。插件不注册全局系统提示词,发送准入只存在于 `send_agent_message` 的工具合同中。Client 只把 relay 投影为可见的 Agent 消息卡片,不会反向把 Agent 消息伪装成人类 `user` 来源。
106
+
107
+ 当前版本没有额外的结果协议:relay 只表达“另一会话发来的消息”,本身不等于必须回复或禁止回复。正文明确要求返回业务结果时,接收 Agent 可用同一工具向 `senderSessionId` 发送结果;没有明确要求时不回传 transport ack 或单纯的“收到”。需要机器可核验的请求/结果关联时,再引入独立 Result 协议。
108
+
109
+ 输入框的 `@` 会话定位复用 Harness 原生 `inputTriggers` 命令标记:选择后的可见标题最多 40 个 Unicode 字符,超出用省略号;提交给当前 Agent 时换成完整 `@session-...` 稳定 ID。发送后的气泡依然用聊天图标和实时会话标题投影该 ID,显示名称变化不会改变定位目标。
121
110
 
122
111
  ## 目录结构
123
112
 
@@ -125,7 +114,7 @@ Agent 会用 bash 执行这条命令,装完自动挂载、所有会话立即
125
114
  dsh-agent-message/
126
115
  ├── lib/
127
116
  │ ├── index.js # host 半区:list_peer_agents / send_agent_message / check_delivery
128
- │ └── client.js # client 半区:发送方会话导航与复制会话ID按钮
117
+ │ └── client.js # client 半区:@会话引用、会话导航与复制会话ID按钮
129
118
  ├── cordis.patch.yml # 自注册补丁(dsh.bundle.patch 指向它)
130
119
  ├── package.json # DSH 插件清单(dsh.bundle / dsh.client / dshx.contributes)
131
120
  ├── docs/ # 设计稿与 README 示例截图
@@ -133,10 +122,14 @@ dsh-agent-message/
133
122
  └── README.en.md # English documentation
134
123
  ```
135
124
 
125
+ ## 正在开发
126
+
127
+ - **跨进程通信**:让运行在不同 DSH 进程中的 Agent 会话也能互相收发消息。
128
+
136
129
  ## 限制
137
130
 
138
131
  - 目标会话必须**未归档**且存在于本机持久化里;归档会话一律拒绝发送。
139
- - 离线激活(wake)会把目标会话以**默认模型**恢复运行(不继承它上次手动切换的模型选择)。
132
+ - 自动恢复离线会话时会使用**默认模型**(不继承它上次手动切换的模型选择);恢复失败时消息不会被写入目标 Inbox。
140
133
  - 不指定 `messageId` 的批量回执依赖内存记账,只覆盖本进程最近 1000 条发送记录(FIFO 淘汰);进程重启后仍可凭已知 `messageId` 查询,但不再返回易失的 `sentAt` 和 `mode`。
141
134
  - 跨进程/跨机器通信不在本插件范围内。
142
135
 
package/cordis.patch.yml CHANGED
@@ -1,13 +1,5 @@
1
1
  # dsh-agent-message bundle patch:把插件挂进 profile 的宿主组合(自注册)。
2
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
3
  - insert:
12
4
  - id: agent-message
13
5
  name: 'dsh-agent-message'
package/lib/client.js CHANGED
@@ -5,12 +5,153 @@ 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");
8
10
 
9
11
  const name = "dsh-agent-message-client";
10
- const inject = ["slots", "timer", "sessions"];
12
+ const inject = ["slots", "timer", "sessions", "inputTriggers", "workspaces"];
11
13
 
12
14
  function apply(ctx) {
13
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();
19
+ const sessionLinks = new Set();
20
+ const workspaces = ctx.get("workspaces");
21
+
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
+ }
45
+
46
+ function compactSessionTitle(row) {
47
+ const chars = Array.from(sessionTitle(row));
48
+ return chars.length > 40 ? chars.slice(0, 39).join("") + "…" : chars.join("");
49
+ }
50
+
51
+ function renderInto(host, content) {
52
+ let root = mountedRoots.get(host);
53
+ if (!root) {
54
+ root = createRoot(host);
55
+ mountedRoots.set(host, root);
56
+ }
57
+ root.render(content);
58
+ }
59
+
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);
69
+ });
70
+ }
71
+
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" });
76
+ }
77
+
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)));
82
+ }
83
+
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 + ":"));
88
+ }
89
+
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 }));
99
+ }
100
+
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");
154
+ }
14
155
 
15
156
  function senderLink(target) {
16
157
  if (!(target instanceof Element)) return null;
@@ -51,12 +192,14 @@ window.__ModuleLoader__.load({
51
192
  const link = senderLink(element);
52
193
  if (!link) return;
53
194
  const relay = element.matches("[data-context-relay-sender]");
54
- const title = relay ? "" : senderTitle(element);
195
+ const title = relay ? sessionTitle(sessionRow(link.sessionId)) || "@" + link.sessionId : senderTitle(element);
55
196
  if (!relay && !title) return;
56
197
  element.dataset.agentMsgSessionId = link.sessionId;
57
198
  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");
199
+ if (relay) renderInto(element, React.createElement(RelaySender, { title: title }));
200
+ else element.textContent = "From Session · " + title + ":";
201
+ element.classList.add("agent-msg-session-link", "agent-msg-sender-link");
202
+ sessionLinks.add(element);
60
203
  element.setAttribute("role", "link");
61
204
  element.setAttribute("tabindex", "0");
62
205
  element.setAttribute("title", "打开发送方会话");
@@ -64,6 +207,104 @@ window.__ModuleLoader__.load({
64
207
  });
65
208
  }
66
209
 
210
+ function prepareRelayCards(root) {
211
+ if (!(root instanceof Element)) return;
212
+ const disclosures = root.matches("[data-disclosure-row]")
213
+ ? [root].concat(Array.from(root.querySelectorAll("[data-disclosure-row]")))
214
+ : Array.from(root.querySelectorAll("[data-disclosure-row]"));
215
+ disclosures.forEach(function (row) {
216
+ if (!String(row.textContent || "").includes("dsh-agent-message")) return;
217
+ if (row.getAttribute("aria-expanded") !== "true") row.click();
218
+ });
219
+
220
+ const bodies = root.matches('[data-context-form="relay"]')
221
+ ? [root].concat(Array.from(root.querySelectorAll('[data-context-form="relay"]')))
222
+ : Array.from(root.querySelectorAll('[data-context-form="relay"]'));
223
+ bodies.forEach(function (body) {
224
+ const text = body.querySelector("[data-context-text]");
225
+ const raw = String(text?.textContent || "");
226
+ const end = raw.indexOf("</dsh-agent-message>");
227
+ if (!raw.startsWith("<dsh-agent-message>") || end < 0) return;
228
+ const card = body.closest('[data-chat-flow-kind="context"]') || body.parentElement;
229
+ card?.setAttribute("data-agent-msg-relay-card", "true");
230
+ if (text.dataset.agentMsgRelayBody !== "true") {
231
+ text.textContent = raw.slice(end + "</dsh-agent-message>".length).trimStart();
232
+ text.dataset.agentMsgRelayBody = "true";
233
+ }
234
+ });
235
+ }
236
+
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
+ function prepare(root) {
291
+ prepareRelayCards(root);
292
+ prepareSenderLinks(root);
293
+ prepareSessionReferences(root);
294
+ prepareSessionMenu(root);
295
+ }
296
+
297
+ function refreshSessionLinks() {
298
+ sessionLinks.forEach(function (element) {
299
+ if (!element.isConnected) {
300
+ sessionLinks.delete(element);
301
+ return;
302
+ }
303
+ if (element.classList.contains("agent-msg-sender-link")) prepareSenderLinks(element);
304
+ else prepareSessionReferences(element);
305
+ });
306
+ }
307
+
67
308
  function openSender(event) {
68
309
  const element = event.target instanceof Element
69
310
  ? event.target.closest(".agent-msg-session-link")
@@ -138,26 +379,50 @@ window.__ModuleLoader__.load({
138
379
  ".agent-msg-copy-id:hover { opacity: 1; border-color: rgba(127,127,127,.7); } " +
139
380
  ".agent-msg-sender-prefix { display: none !important; } " +
140
381
  ".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; } " +
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
+ "[data-agent-msg-relay-card] [data-disclosure-row] { display: none !important; } " +
390
+ "[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
+ "[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
+ "[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; } " +
142
399
  ".agent-msg-session-link:hover { text-decoration: underline; text-underline-offset: 3px; } " +
143
400
  ".agent-msg-session-link:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; border-radius: 2px; }";
144
401
  const tag = document.createElement("style");
145
402
  tag.setAttribute("data-plugin", name);
146
403
  tag.textContent = css;
147
404
  document.head.appendChild(tag);
148
- prepareSenderLinks(document.body);
405
+ prepare(document.body);
149
406
  const observer = new MutationObserver(function (records) {
150
407
  records.forEach(function (record) {
151
- record.addedNodes.forEach(prepareSenderLinks);
408
+ record.removedNodes.forEach(cleanupRoots);
409
+ record.addedNodes.forEach(prepare);
152
410
  });
153
411
  });
154
412
  observer.observe(document.body, { childList: true, subtree: true });
413
+ const unsubscribeSessions = ctx.sessions.list.subscribe(refreshSessionLinks);
414
+ const unsubscribeWorkspaces = workspaces?.list.subscribe(refreshSessionLinks);
155
415
  document.addEventListener("click", openSender);
156
416
  document.addEventListener("keydown", onKeyDown);
157
417
  ctx.effect(() => () => {
158
418
  observer.disconnect();
419
+ unsubscribeSessions();
420
+ unsubscribeWorkspaces?.();
159
421
  document.removeEventListener("click", openSender);
160
422
  document.removeEventListener("keydown", onKeyDown);
423
+ mountedRoots.forEach(function (root) { root.unmount(); });
424
+ mountedRoots.clear();
425
+ sessionLinks.clear();
161
426
  tag.remove();
162
427
  });
163
428
  }
package/lib/index.js CHANGED
@@ -1,32 +1,42 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
+ import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm/message'
2
3
 
3
4
  export const name = 'dsh-agent-message'
4
- export const inject = ['agents', 'tools']
5
+ export const inject = ['agents', 'tools', 'sessionQuery']
5
6
 
6
- export function apply(ctx, config) {
7
+ export function apply(ctx) {
7
8
  const agents = ctx.agents
8
- /** 消息渲染形态:'user'=对话式气泡、期待回复(默认)|'relay'=上下文注入、静默影响代理行为、不期待回复。 */
9
- const form = config?.form === 'relay' ? 'relay' : 'user'
10
- let seq = 0
11
9
  /** messageId -> { to, at, mode };发送成功即记账,供批量查询及补充本进程发送信息。 */
12
10
  const sent = new Map()
13
11
  /** 记账表 FIFO 上限:超过则淘汰最老记录,内存恒定。 */
14
12
  const SENT_MAX = 1000
13
+ const receiptMeaning = 'claimed 仅表示目标 turn 已从 Inbox 认领消息;传输回执不表示对方已读、回复或完成。'
14
+ /** 插件恢复的 Session handle;保留到插件卸载,避免 idle dispose 移除 Harness store 投影。 */
15
+ const resumedHandles = new Map()
16
+
17
+ ctx.effect(() => async () => {
18
+ const handles = [...resumedHandles.values()]
19
+ resumedHandles.clear()
20
+ const settled = await Promise.allSettled(handles.map(async (pending) => (await pending).dispose()))
21
+ for (const result of settled) {
22
+ if (result.status === 'rejected') ctx.logger?.warn?.('释放恢复会话失败:' + String(result.reason))
23
+ }
24
+ }, 'dsh-agent-message: resumed agent handles')
25
+
26
+ ctx.on('agent/disposed', ({ agent }) => {
27
+ const key = String(agent.id)
28
+ const pending = resumedHandles.get(key)
29
+ if (pending === undefined) return
30
+ pending.then((handle) => {
31
+ if (handle.agent === agent && resumedHandles.get(key) === pending) resumedHandles.delete(key)
32
+ }, () => {})
33
+ })
15
34
 
16
35
  function rememberSent(messageId, to, mode) {
17
36
  sent.set(messageId, { to, at: Date.now(), mode })
18
37
  if (sent.size > SENT_MAX) sent.delete(sent.keys().next().value)
19
38
  }
20
39
 
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
40
  function titleOf(agent) {
31
41
  const service = ctx.get('sessionTitle')
32
42
  if (service !== undefined) {
@@ -57,72 +67,69 @@ export function apply(ctx, config) {
57
67
  return new Set((workspace !== undefined ? workspace.archivedSessionIds : []).map((id) => String(id)))
58
68
  }
59
69
 
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
- }
70
+ function isSubagentSession(header) {
71
+ return header?.origin === 'subagent'
72
+ }
73
+
74
+ function sessionQuery() {
75
+ const query = ctx.get('sessionQuery')
76
+ if (query === undefined) throw new Error('本部署缺少 sessionQuery,无法查询逻辑会话')
77
+ return query
85
78
  }
86
79
 
87
- /** 留言投递:写入失败时若目标恰好上线,自动转在线投递(模型无感,不报错)。 */
88
- async function deliverLeave(id, message) {
80
+ async function readLogicalSession(id) {
89
81
  try {
90
- await leaveOffline(id, message)
91
- return { usedMode: 'leave', fallback: '' }
82
+ return await sessionQuery().readSession(id)
92
83
  } catch (error) {
93
- const nowLive = agents.get(id)
94
- if (nowLive !== undefined) {
95
- nowLive.followup(message)
96
- return { usedMode: 'followup', fallback: '留言写入时目标恰好上线,已改为在线投递' }
97
- }
84
+ if (error?.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return undefined
98
85
  throw error
99
86
  }
100
87
  }
101
88
 
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
89
+ /** 冷会话投递:公开 resume + followup;同一 Session 复用 handle,仅在插件卸载时释放。 */
90
+ async function resumeAndFollowup(id, message, inspected) {
91
+ const existing = agents.get(id)
92
+ if (existing !== undefined) {
93
+ existing.followup(message)
94
+ return existing
95
+ }
96
+ const key = String(id)
97
+ let pending = resumedHandles.get(key)
98
+ if (pending === undefined) {
99
+ let presetId = inspected.session.agentPreset
100
+ const events = inspected.events ?? []
101
+ for (let i = events.length - 1; i >= 0; i--) {
102
+ const ev = events[i]
103
+ if (ev && ev.type === 'agent-preset/selected') {
104
+ presetId = ev.data.agentPreset
105
+ break
106
+ }
114
107
  }
108
+ const presets = ctx.get('agentPresets')
109
+ const defaultModel = ctx.get('agentDefaultModel')
110
+ const selection = defaultModel !== undefined ? defaultModel.currentSelection() : { provider: '', model: '' }
111
+ pending = agents.resume({
112
+ resumeSessionId: id,
113
+ agentOptions: { provider: selection.provider ?? '', model: selection.model ?? '' },
114
+ ...(presets !== undefined && presetId !== undefined
115
+ ? { setup: async (agentCtx) => { await presets.mount(agentCtx, presetId) } }
116
+ : {}),
117
+ })
118
+ resumedHandles.set(key, pending)
119
+ pending.catch(() => {
120
+ if (resumedHandles.get(key) === pending) resumedHandles.delete(key)
121
+ })
122
+ }
123
+
124
+ let handle
125
+ try {
126
+ handle = await pending
127
+ } catch (error) {
128
+ const concurrent = agents.get(id)
129
+ if (concurrent === undefined) throw error
130
+ concurrent.followup(message)
131
+ return concurrent
115
132
  }
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
133
  handle.agent.followup(message)
127
134
  return handle.agent
128
135
  }
@@ -138,10 +145,9 @@ export function apply(ctx, config) {
138
145
  discarded: state.discarded,
139
146
  }
140
147
  }
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))
148
+ const inspected = await readLogicalSession(to)
149
+ if (inspected === undefined) return { pending: new Set(), claimed: new Set(), discarded: new Set() }
150
+ const state = foldInbox(inspected.events.slice(inspected.session.seedLength ?? 0))
145
151
  return {
146
152
  pending: new Set(state['next-turn'].concat(state['next-step']).map((message) => message.id)),
147
153
  claimed: state.claimed,
@@ -149,29 +155,21 @@ export function apply(ctx, config) {
149
155
  }
150
156
  }
151
157
 
152
- /** 回执状态:delivered 排队中 / claimed 已认领 / discarded 被丢弃 / unknown 查无此消息。 */
158
+ /** 回执状态:pending 排队中 / claimed 已认领 / discarded 被丢弃 / unknown 查无此消息。 */
153
159
  function deliveryStateOf(messageId, snapshot) {
154
- if (snapshot.pending.has(messageId)) return 'delivered'
160
+ if (snapshot.pending.has(messageId)) return 'pending'
155
161
  if (snapshot.discarded.has(messageId)) return 'discarded'
156
162
  if (snapshot.claimed.has(messageId)) return 'claimed'
157
163
  return 'unknown'
158
164
  }
159
165
 
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
166
  ctx.tools.register(defineTool({
169
167
  name: 'list_peer_agents',
170
168
  description:
171
- '列出当前进程里所有可发送(未归档)的 DeepSeek Harness Agent/会话,用于跨会话通信。' +
169
+ '列出逻辑会话目录中所有可发送(未归档且不是子代理)的 DeepSeek Harness 会话,用于跨会话通信。' +
172
170
  '每条含:id(会话 ID)、标题、工作目录、status(offline=进程里未加载、重启后未打开;其余为在线)、' +
173
- 'kindpeer=平级会话 / subagent=子代理)。在线在前、按标题排序。' +
174
- '找到目标会话后,用它的 id 调用 send_agent_message 发送消息(目标离线也会自动激活或留言)。' +
171
+ 'kind(固定为 peer,作为兼容字段)。在线在前、按标题排序。普通 fork 即使有 parentSession 也仍是独立会话。' +
172
+ '找到目标会话后,用它的 id 调用 send_agent_message 发送消息(目标离线时自动恢复后投递,恢复失败则返回错误)。' +
175
173
  '注意:它不同于 list_agents(后者列的是你的后台子代理)。',
176
174
  parameters: {},
177
175
  output: {
@@ -183,41 +181,30 @@ export function apply(ctx, config) {
183
181
  async execute(_args, exec) {
184
182
  const me = exec.agent
185
183
  const archived = archivedIds()
186
- const persistence = ctx.get('sessionPersistence')
184
+ const query = sessionQuery()
187
185
  const live = new Map()
188
186
  for (const agent of agents.list()) live.set(String(agent.id), agent)
189
187
 
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
188
  const rows = []
197
- for (const id of ids) {
189
+ const records = await query.listSessions()
190
+ for (const record of records) {
191
+ const header = record.header
192
+ const id = String(header.id)
198
193
  if (archived.has(id)) continue
194
+ if (isSubagentSession(header)) continue
199
195
  const agent = live.get(id)
200
- const header = headerMap.get(id)
201
196
  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 })
197
+ rows.push({ id, title: '', cwd: header.cwd ?? '', status, kind: 'peer', self: me !== undefined && String(me.id) === id })
208
198
  }
209
199
 
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
- }
200
+ const snapshots = await query.readTitleSnapshots(rows.map((r) => r.id))
201
+ const titleMap = new Map()
202
+ for (const t of snapshots) {
203
+ if (t.status === 'fulfilled' && t.value !== undefined && t.value.title !== undefined && typeof t.value.title.title === 'string') {
204
+ titleMap.set(String(t.sessionId), t.value.title.title)
218
205
  }
219
- for (const row of rows) row.title = titleMap.get(row.id) ?? ''
220
206
  }
207
+ for (const row of rows) row.title = titleMap.get(row.id) ?? ''
221
208
 
222
209
  const rank = (row) => (row.status === 'running' ? 0 : row.status === 'idle' ? 1 : 2)
223
210
  rows.sort((a, b) => {
@@ -236,14 +223,19 @@ export function apply(ctx, config) {
236
223
  ctx.tools.register(defineTool({
237
224
  name: 'send_agent_message',
238
225
  description:
239
- '向另一个 Agent/会话发送消息(跨会话通信)。默认立即送达:目标在线则引导其当前工作(steer);' +
240
- '目标离线(进程里未加载)则自动激活该会话并投递(wake),激活失败自动改为留言(leave)。' +
241
- 'mode 可选:steer/followup/inject(仅在线)、leave(留言不唤醒)、wake(激活)。归档会话一律拒绝。' +
226
+ '向指定 Agent 或 Session 投递消息,用于执行当前请求或用户已授予的编排职责中的跨会话通信。' +
227
+ '@session 只提供目标,不代表发送。' +
228
+ '收到 relay 消息时,只有正文明确要求向发送方返回内容时才使用本工具回复 senderSessionId;' +
229
+ '不要回传单纯的 transport ack 或“收到”。' +
230
+ '默认使用 followup 创建独立的新 turn;' +
231
+ '目标离线(进程里未加载)时自动恢复该会话后投递。' +
232
+ '用户无需说出模式名:目标为 running 且整句明确要求立即介入时用 steer,明确要求不打断当前任务、只补充上下文时用 inject;不确定时使用 followup。' +
233
+ '归档会话和子代理一律拒绝。' +
242
234
  '注意:它不同于 send_message(后者是给你的后台子代理续聊)。',
243
235
  parameters: {
244
236
  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 激活。不传则智能默认。' },
237
+ content: { type: 'string', required: true, description: '只填用户要求转达的消息文本,不要自行追加“收到”、“请确认”或其他 transport ack 要求。' },
238
+ mode: { type: 'string', enum: ['followup', 'inject', 'steer'], description: '不确定时省略并使用 followup;steer=立即介入 running 任务;inject=不打断地补充 running 任务上下文。' },
247
239
  },
248
240
  output: {
249
241
  schema: { type: 'json' },
@@ -259,56 +251,47 @@ export function apply(ctx, config) {
259
251
 
260
252
  const archived = archivedIds()
261
253
  if (archived.has(String(to))) throw new Error('对方会话已归档,无法发送(请先取消归档)')
254
+ const mode = args.mode ?? 'followup'
255
+ const target = agents.get(to)
256
+ if (target !== undefined && isSubagentSession(target.session.header)) {
257
+ throw new Error('目标是子代理,不能通过会话通信插件直接发送')
258
+ }
259
+ if (target !== undefined && mode !== 'followup' && target.status !== 'running') {
260
+ throw new Error(mode + ' 仅用于 running 会话;目标当前状态:' + target.status)
261
+ }
262
262
 
263
263
  const myTitle = titleOf(me) || String(me.id)
264
264
  const source = {
265
- kind: form === 'relay' ? 'plugin' : 'user',
266
- plugin: name,
267
- form,
265
+ kind: name,
266
+ form: 'relay',
267
+ protocolVersion: 1,
268
268
  senderSessionId: String(me.id),
269
+ targetSessionId: String(to),
269
270
  senderTitle: myTitle,
270
271
  }
271
- const message = makeMessage(form === 'relay' ? args.content : 'From Session · ' + myTitle + ': @' + String(me.id) + '\n' + args.content, source)
272
+ const identified = createUserMessage({ content: [{ type: 'text', text: args.content }], source })
273
+ const relayHeader = JSON.stringify({ senderSessionId: source.senderSessionId })
274
+ const message = freezeMessage({
275
+ ...identified,
276
+ content: [{ type: 'text', text: '<dsh-agent-message>' + relayHeader + '</dsh-agent-message>\n\n' + args.content }],
277
+ })
272
278
 
273
- const target = agents.get(to)
274
279
  let usedMode = ''
275
- let fallback = ''
280
+ let targetRuntimeStatus = 'offline'
276
281
 
277
282
  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' }
283
+ if (mode === 'followup') { target.followup(message); usedMode = mode }
281
284
  else if (mode === 'inject') { target.inject(message); usedMode = 'inject' }
282
285
  else { target.steer(message); usedMode = 'steer' }
286
+ targetRuntimeStatus = target.status
283
287
  } 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
- }
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
312
295
  }
313
296
 
314
297
  rememberSent(message.id, String(to), usedMode)
@@ -317,8 +300,9 @@ export function apply(ctx, config) {
317
300
  to: String(to),
318
301
  mode: usedMode,
319
302
  messageId: message.id,
320
- ...(fallback !== '' ? { fallback } : {}),
321
- text: fallback !== '' ? fallback : '已通过 ' + usedMode + ' 模式向 Agent ' + String(to) + ' 发送消息。',
303
+ state: 'accepted',
304
+ targetRuntimeStatus,
305
+ text: '会话 ' + String(to) + ' 已接受投递(' + usedMode + ')。这不表示对方已读、回复或完成。',
322
306
  }
323
307
  },
324
308
  }))
@@ -327,7 +311,8 @@ export function apply(ctx, config) {
327
311
  name: 'check_delivery',
328
312
  description:
329
313
  '按需查询发给某会话的消息状态(跨会话回执,默认安静——只有监督场景主动调用时才返回,不做任何自动播报)。' +
330
- '状态:delivered=已送达·排队中;claimed=已被对方认领;discarded=被丢弃;unknown=查无此消息。' +
314
+ '状态:pending=仍在目标 Inbox 排队;claimed=已被对方认领;discarded=被丢弃;unknown=查无此消息。' +
315
+ 'claimed 不表示已读、已回复或任务完成。' +
331
316
  '传 messageId 时可在进程重启后从目标 Inbox 日志恢复状态;不传则只返回本进程内发给该会话的全部已记账消息。',
332
317
  parameters: {
333
318
  to: { type: 'string', required: true, description: '目标会话 ID。' },
@@ -341,11 +326,11 @@ export function apply(ctx, config) {
341
326
  },
342
327
  async execute(args) {
343
328
  const target = agents.get(args.to)
344
- const targetStatus = target !== undefined ? target.status : 'offline'
329
+ const targetRuntimeStatus = target !== undefined ? target.status : 'offline'
345
330
  if (args.messageId !== undefined) {
346
331
  const entry = sent.get(args.messageId)
347
332
  if (entry !== undefined && String(entry.to) !== String(args.to)) {
348
- return { to: args.to, entries: [{ messageId: args.messageId, state: 'unknown', targetStatus }] }
333
+ return { to: args.to, receiptMeaning, entries: [{ messageId: args.messageId, state: 'unknown', targetRuntimeStatus }] }
349
334
  }
350
335
  }
351
336
  const wanted = args.messageId !== undefined
@@ -361,10 +346,10 @@ export function apply(ctx, config) {
361
346
  messageId,
362
347
  ...(entry !== undefined ? { sentAt: entry.at, mode: entry.mode } : {}),
363
348
  state,
364
- targetStatus,
349
+ targetRuntimeStatus,
365
350
  })
366
351
  }
367
- return { to: args.to, entries }
352
+ return { to: args.to, receiptMeaning, entries }
368
353
  },
369
354
  }))
370
355
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-agent-message",
3
- "version": "1.4.0",
3
+ "version": "1.5.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"],
@@ -27,7 +27,9 @@
27
27
  "inject": [
28
28
  "@deepseek-ai/dsh-client-runtime",
29
29
  "@deepseek-ai/dsh-client-ui-slots",
30
- "@deepseek-ai/dsh-client-ui-conversation"
30
+ "@deepseek-ai/dsh-client-ui-conversation",
31
+ "@deepseek-ai/dsh-client-ui-input-trigger",
32
+ "@deepseek-ai/dsh-client-ui-primitives"
31
33
  ]
32
34
  }
33
35
  },
@@ -39,9 +41,11 @@
39
41
  },
40
42
  "peerDependencies": {
41
43
  "@deepseek-ai/cordis": "^4.0.1",
44
+ "@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0",
42
45
  "@deepseek-ai/dsh-tools": ">=0.1.0-rc.6 <0.2.0"
43
46
  },
44
47
  "devDependencies": {
48
+ "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
45
49
  "@deepseek-ai/dsh-tools": "0.1.0-rc.6"
46
50
  },
47
51
  "engines": {