dsh-session-chat 0.0.0-stage → 0.1.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.md CHANGED
@@ -1,3 +1,193 @@
1
- # Temporary Holding Version
1
+ # dsh-session-chat
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **实验性插件(MVP)**:让 DSH 里两个**独立会话**互相发纯文本消息。
4
+ > 只对话,不碰文件;不代替 subagent / Agent Teams 做任务委派。
5
+ >
6
+ > 开发/验证/运维记录(安装路径、启动安全、验证证据、坑清单)见工作区文档
7
+ > [`docs/插件-dsh-session-chat.md`](../docs/插件-dsh-session-chat.md)。
8
+
9
+ ## 它解决什么问题
10
+
11
+ DSH 的平级会话(两个 root session)之间没有消息通道:`send_message` 只对子会话/父会话生效,
12
+ `@` 引用只是只读快照。于是「两个会话在同一个仓库里改到了同一批文件,需要当场协商」时,
13
+ 只能靠人来回转达。
14
+
15
+ 这个插件在 host 进程里注册两个工具,把消息直接投进目标会话的 inbox——用宿主自己的
16
+ `agent-message` 消息源(与 Agent Teams 内部同一套投递机制),因此历史持久化、会话回放、
17
+ UI 渲染都天然兼容。
18
+
19
+ ## 两个工具
20
+
21
+ ### `session_peers`
22
+
23
+ 列出**同工作区**的 live 会话(默认不含自己):
24
+
25
+ ```jsonc
26
+ { "count": 1, "peers": [
27
+ { "sessionId": "session-bbbbbbbb-...", "shortId": "22222222", "status": "idle",
28
+ "root": true, "self": false, "sameWorkspace": true, "canReceive": true,
29
+ "cwd": "D:\\code\\iwhaleCloud" }
30
+ ] }
31
+ ```
32
+
33
+ ### `session_send`
34
+
35
+ 把一个会话的文本投递到另一个会话:
36
+
37
+ ```jsonc
38
+ // 参数
39
+ { "target": "session-bbbbbbbb-...", "message": "UserGroup.java 归你,我的分支已让开;你先合入 proc" }
40
+ // 返回
41
+ { "delivered": true, "messageId": "…", "targetSessionId": "…", "targetShortId": "…", "targetStatus": "idle" }
42
+ ```
43
+
44
+ 目标侧看到的是普通用户消息(前缀 `Agent <发送方 session id> sent a message: `),
45
+ source 为 `{ kind: 'agent-message', form: 'relay', senderSessionId }`:
46
+
47
+ - 目标**正在运行** → 消息在它最近的 step 边界注入(steer);
48
+ - 目标**空闲** → 直接开始新的一轮(steer 对 idle driver 起 turn);
49
+ - 回消息:用 `session_send`,target 填对方 session id(从消息前缀里读)。
50
+
51
+ > **主路径是"用户直接给 id"**:DSH 会话列表每行的菜单里有「复制会话 ID」,把那个 id 贴进
52
+ > prompt 即可(带引号/反引号/空格也能识别)。`session_peers` 只是不知道 id、或需要确认
53
+ > 对方是否 live 时的**可选发现助手**,不是必经步骤。
54
+
55
+ ## 边界(重要)
56
+
57
+ 1. **只投递文本**。插件不读写文件、不给工具、不给权限。收到消息的会话仍然按它自己的
58
+ permission / sandbox 行事——要「只聊不改文件」,得让它处于 plan / 只读预设。
59
+ 2. **只投递 live 会话**。不会冷启动一个没打开的会话(避免一句话偷偷唤醒会话、消耗 token)。
60
+ 3. **只允许同工作区**(双方 `cwd` 归一化后相等,Windows 大小写不敏感)。
61
+ 4. **默认只允许 root 会话**(`delegationDepth === 0` 且无 `parentSession`);subagent / teammate
62
+ 有各自的投递路径,不让它们走这条。可用 `allowNonRootSessions: true` 放开。
63
+ 5. **限流**:每对 (sender, target) 默认 `30 条 / 10 分钟`(进程内,重启清零);单条默认 8 KB。
64
+ 6. **跨会话内容本质上是不可信输入**:目标模型会把它当用户消息读。只在你自己信任的会话之间使用;
65
+ 白名单不是可选项,是给"多会话共享 profile"场景兜底的。
66
+
67
+ ## 配置
68
+
69
+ 在 profile 的 bundle patch(`cordis.patch.yml`)里:
70
+
71
+ ```yaml
72
+ - insert:
73
+ - id: session-chat
74
+ name: 'dsh-session-chat'
75
+ config:
76
+ disabled: false # true = 加载但什么都不注册(kill switch)
77
+ allowlist: [] # 目标会话白名单(session id 或 uuid);空 = 同工作区 root 即可
78
+ maxMessageBytes: 8192
79
+ maxMessagesPerWindow: 30
80
+ windowMs: 600000
81
+ allowNonRootSessions: false
82
+ ```
83
+
84
+ 非法配置**不会**阻断加载:类型不对/越界的值回落默认值。
85
+
86
+ ## 构建与自测
87
+
88
+ ```powershell
89
+ cd D:\code\local\dsh-session-chat
90
+ node scripts\gen-typecheck-paths.mjs # 生成 351 个 @deepseek-ai/* path 映射(本机沙箱不允许 junction)
91
+ npx tsc -p tsconfig.typecheck.json # 对真实宿主 .d.ts 做类型检查
92
+ npx tsdown # 产物:lib/index.js + lib/index.d.ts
93
+ node scripts\validate-schemas.mjs # 用真实宿主 tools 包校验 schema(register + 参数校验)
94
+ node scripts\probe.mjs # 假 ctx 探针:18 项检查(投递形状 + 全部拒绝分支)
95
+ ```
96
+
97
+ 三个校验脚本都不需要安装进 DSH,也不会碰真实会话。`npm run verify` 可一次跑后两个。
98
+
99
+ ## 安装(需要重启应用)
100
+
101
+ 两种方式,任选其一:
102
+
103
+ **A. 从 npm 安装(推荐,发布后)**
104
+
105
+ ```powershell
106
+ dsh plugin --profile desktop add dsh-session-chat
107
+ ```
108
+
109
+ **B. 从本地工程装(开发期预览)**
110
+
111
+ 安装脚本写的是 **profile 目录**(工作区之外),请在你自己的终端里执行:
112
+
113
+ ```powershell
114
+ cd D:\code\local\dsh-session-chat
115
+ pwsh -NoProfile -File .\scripts\deploy.ps1 # 复制到 profile + 加进 bundles(自动备份 package.json)
116
+ pwsh -NoProfile -File .\scripts\deploy.ps1 -Uninstall # 卸载
117
+ ```
118
+
119
+ 脚本会:
120
+ 1. 把 `lib/`、`package.json`、`cordis.patch.yml` 拷到
121
+ `%DSH_HOME%\profiles\desktop\node_modules\dsh-session-chat\`;
122
+ 2. 备份 profile 的 `package.json` 为 `package.json.bak-session-chat-<时间戳>`;
123
+ 3. 把 `dsh-session-chat` 追加到 `dsh.profile.bundles`。
124
+
125
+ 然后**手动关闭并重开应用**(host 插件必须重启才加载)。验证:
126
+ 1. 新开会话里问模型「调 session_peers 看看有哪些会话」;
127
+ 2. 用 `session_send` 给另一个会话发一句;
128
+ 3. 在目标会话里确认收到 `Agent … sent a message:`。
129
+
130
+ ⚠️ 装坏了导致启动失败时:对话框点「重启应用」,**不要点「禁用第三方插件」**(那会改名
131
+ `cordis.patch.yml` 并把 bundles 重置为官方两项,见工作区 AGENTS.md)。回滚用 `-Uninstall`。
132
+
133
+ > ⚠️ **已知安装侧注意事项**:
134
+ > - profile 用 pnpm 管理依赖。`pnpm install`(含插件页里的安装/卸载操作触发的 reconcile)
135
+ > 可能清掉手工放进 `node_modules` 的目录、并把不在 `dependencies` 里的 bundle 条目移出
136
+ > `dsh.profile.bundles`——**升级/装插件后重新执行 `deploy.ps1` 即可**。
137
+ > - 后续优化会把包打成 `file:*.tgz` 依赖写进 profile `package.json`,让 bundle 条目在
138
+ > pnpm 操作后仍被保留(本机沙箱下 `npm pack` 写不了缓存,暂缓)。
139
+ > - 本插件是 host 插件,装/升级都需要重启应用;客户端不需要任何东西。
140
+
141
+ ## 启动风险与恢复(重要)
142
+
143
+ host 插件加载失败会影响应用启动。DSH 的失败分三层:
144
+
145
+ | 失败点 | 后果 | 处理 |
146
+ |---|---|---|
147
+ | 包解析失败 / manifest 缺 `dsh.bundle` / dsh 版本不兼容 | 该 bundle 被 **skipped**,应用照常启动(只是插件不生效) | 重跑 `deploy.ps1` |
148
+ | `apply` 抛错(工具注册冲突等) | `plugin tree failed to load` → 应用起不来 → 致命对话框 | 见下 |
149
+ | 运行期(工具调用 / 投递 / 目标会话处理) | 只影响该次工具结果或目标会话那一轮,**不会挂进程** | 重试、等对方空闲 |
150
+
151
+ 本插件为"绝不拖垮启动"做了这些:
152
+
153
+ - `apply` 全程 `try/catch`:失败只打一条 `[session-chat] …` warning,插件保持惰性;
154
+ - 两个工具**分别注册**,一个失败只禁用该工具,不影响另一个;
155
+ - 运行期零 `@deepseek-ai/*` import(只用 `node:` 内置模块),不存在宿主包解析失败;
156
+ - 配置非法只回落默认值,不抛错;
157
+ - `deploy.ps1` 装完会用纯 node import 一次产物做预检,失败自动**回滚并恢复** profile `package.json`。
158
+
159
+ **真的起不来时**(对话框只有 退出 / 重启 / 禁用第三方插件):
160
+
161
+ 1. 先点「**重启应用**」;持续失败**千万不要点「禁用第三方插件」**——它会改名
162
+ `cordis.patch.yml` 并把 bundles 重置为官方两项(见工作区 AGENTS.md)。
163
+ 2. 安全卸载(应用未运行时执行):
164
+ ```powershell
165
+ pwsh -NoProfile -File .\scripts\deploy.ps1 -Uninstall
166
+ ```
167
+ 3. 或用 kill switch(不删包):在 profile 的 `cordis.patch.yml` 加
168
+ ```yaml
169
+ - id: session-chat
170
+ config:
171
+ disabled: true
172
+ ```
173
+ 重启后插件照常加载,但不注册任何工具。
174
+ 4. 诊断:致命对话框会给崩溃报告文件路径;插件自身的 warning 以 `[session-chat]` 开头。
175
+
176
+ ## 已知限制(MVP,后续优化)
177
+
178
+ - 每个消息会让目标跑一个完整 turn:延迟 = 一个 step,token/上下文成本真实存在;长对话会触发 compaction。
179
+ - 没有标题:`session_peers` 只能给 id/状态,不能给会话标题(标题在投影里,尚未接入)。
180
+ - 限流是进程内状态,重启清零;也没有跨窗口的"沙漏"统计。
181
+ - 不跨进程:两个会话必须在同一个 DSH host 进程(桌面端默认满足)。
182
+ - 不跨工作区;不冷启动;不允许非 root 会话。
183
+ - 没有消息去重/重试:投递失败就是失败,调用方自己重发。
184
+ - 没有设置页/UI:配置走 bundle patch。
185
+
186
+ ## 后续优化方向
187
+
188
+ - 接入会话标题(`session_peers` 显示 title,体验立刻好一大截);
189
+ - 冷启动开关(`allowColdResume`,需显式开启);
190
+ - 限流持久化 + 会话对状态(谁在等谁);
191
+ - 消息去重(按 `messageId`)与送达回执;
192
+ - 跨会话消息的"未读/待回"提醒(客户端 UI);
193
+ - 与 Agent Teams 融合:Team 内部已能互发,这里只补"两个独立 root"的场景。
@@ -0,0 +1,7 @@
1
+ # dsh-session-chat bundle patch(供 profile bundles 列表加载)
2
+ # id 是插件服务名(= 包名去掉 dsh- 前缀,与 token-guard / read-source 一致);name 是 npm 包名。
3
+ # ⚠️ 必须是顶层数组:只有注释没有 `- ` 项 → YAML 解析成 null → 宿主启动中止。
4
+ - insert:
5
+ - id: session-chat
6
+ name: 'dsh-session-chat'
7
+ config: {}
package/lib/index.d.ts ADDED
@@ -0,0 +1,252 @@
1
+ import { Context } from "@deepseek-ai/cordis";
2
+ import { ToolDefinition } from "@deepseek-ai/dsh-tools";
3
+ //#region src/config.d.ts
4
+ /**
5
+ * 插件配置:全部可选,非法值**不阻断加载**,回落默认值(插件配置写错不该让应用起不来)。
6
+ *
7
+ * 配置来自 profile 的 bundle patch:`cordis.patch.yml` 里 `- insert: - id: session-chat ... config: {...}`。
8
+ */
9
+ /** 插件配置的原始输入(loader 注入,字段全部可选)。 */
10
+ interface SessionChatConfig {
11
+ /**
12
+ * 紧急开关:`true` 时插件照常加载但什么都不注册(启动安全的 kill switch)。
13
+ * 用 profile 的 `cordis.patch.yml` 覆盖同一个 id 即可生效,不需要删包。
14
+ */
15
+ readonly disabled?: boolean;
16
+ /**
17
+ * 目标会话白名单(session id 精确匹配,或只写 uuid 部分)。留空 = 允许“同工作区的 root 会话”。
18
+ * 一旦填写,只有名单里的会话能收到消息。
19
+ */
20
+ readonly allowlist?: readonly string[];
21
+ /** 单条消息的 UTF-8 上限,默认 8192。超限拒绝,不截断。 */
22
+ readonly maxMessageBytes?: number;
23
+ /** 同一对 (sender, target) 在一个窗口内最多可发多少条,默认 30。 */
24
+ readonly maxMessagesPerWindow?: number;
25
+ /** 限流窗口,毫秒,默认 600000(10 分钟)。 */
26
+ readonly windowMs?: number;
27
+ /**
28
+ * 是否允许“非 root 会话”(subagent / teammate 子会话)参与:
29
+ * 默认 false —— 发送方和目标都必须是 root 会话。
30
+ */
31
+ readonly allowNonRootSessions?: boolean;
32
+ }
33
+ /** 归一化后的配置。 */
34
+ interface ResolvedSessionChatConfig {
35
+ readonly disabled: boolean;
36
+ readonly allowlist: readonly string[];
37
+ readonly maxMessageBytes: number;
38
+ readonly maxMessagesPerWindow: number;
39
+ readonly windowMs: number;
40
+ readonly allowNonRootSessions: boolean;
41
+ }
42
+ /**
43
+ * 归一化配置:类型不对、越界、空值都回落默认值,不抛错。
44
+ * @param input - loader 注入的原始配置(可能为 undefined)。
45
+ * @returns 可直接使用的配置。
46
+ */
47
+ declare function resolveSessionChatConfig(input?: unknown): ResolvedSessionChatConfig;
48
+ //#endregion
49
+ //#region src/message.d.ts
50
+ /** 一条跨会话消息(满足 `UserMessage` 的运行时形状)。 */
51
+ interface SessionChatMessage {
52
+ readonly id: string;
53
+ readonly role: 'user';
54
+ readonly content: readonly {
55
+ readonly type: 'text';
56
+ readonly text: string;
57
+ }[];
58
+ readonly source: {
59
+ readonly kind: 'agent-message';
60
+ readonly form: 'relay';
61
+ readonly senderSessionId: string;
62
+ };
63
+ }
64
+ /**
65
+ * 构造一条投递给目标会话的用户消息。
66
+ * @param senderSessionId - 发送方会话 id(写进 source 与文本前缀)。
67
+ * @param text - 发送方给出的正文。
68
+ * @returns 已深冻结的消息对象。
69
+ */
70
+ declare function buildSessionChatMessage(senderSessionId: string, text: string): SessionChatMessage;
71
+ /**
72
+ * 递归冻结(消息进入 inbox 前应与宿主 `createMessage` 一样不可变)。
73
+ * @param value - 任意对象。
74
+ * @returns 同一个对象。
75
+ */
76
+ declare function deepFreeze<T>(value: T): T;
77
+ /**
78
+ * 计算文本的 UTF-8 字节数。
79
+ * @param text - 任意文本。
80
+ * @returns 字节数。
81
+ */
82
+ declare function utf8Bytes(text: string): number;
83
+ //#endregion
84
+ //#region src/limits.d.ts
85
+ /**
86
+ * 每对 (sender, target) 的滑动窗口限流。
87
+ *
88
+ * 为什么需要:会话聊天是**互相消耗模型 turn** 的,两个模型可以无限 ping-pong。
89
+ * 限流把“失控对喷”变成一条可读的 SESSION_CHAT_RATE_LIMITED 错误,让模型停下来。
90
+ */
91
+ /** 限流判定结果。 */
92
+ type RateLimitVerdict = {
93
+ readonly ok: true;
94
+ } | {
95
+ readonly ok: false;
96
+ readonly retryAfterMs: number;
97
+ };
98
+ /** 进程内滑动窗口限流器(不持久化;应用重启即清零,符合 MVP 预期)。 */
99
+ declare class PairRateLimiter {
100
+ private readonly maxPerWindow;
101
+ private readonly windowMs;
102
+ private readonly stamps;
103
+ /**
104
+ * @param maxPerWindow - 窗口内允许的消息条数(>=1)。
105
+ * @param windowMs - 窗口长度(毫秒,>=1)。
106
+ */
107
+ constructor(maxPerWindow: number, windowMs: number);
108
+ /**
109
+ * 尝试占用一个配额。只有真正要投递时才调用(参数校验之后再调用,避免坏消息吃掉配额)。
110
+ * @param sender - 发送方会话 id。
111
+ * @param target - 目标会话 id。
112
+ * @param now - 当前时间戳(毫秒,测试可注入)。
113
+ * @returns 放行,或建议的重试等待时间。
114
+ */
115
+ check(sender: string, target: string, now: number): RateLimitVerdict;
116
+ /** 清空状态(测试用)。 */
117
+ reset(): void;
118
+ }
119
+ //#endregion
120
+ //#region src/agents.d.ts
121
+ /** `SessionHeader` 的窄视图。 */
122
+ interface SessionHeaderLike {
123
+ readonly id: string;
124
+ readonly cwd?: string;
125
+ readonly parentSession?: string;
126
+ readonly delegationDepth?: number;
127
+ }
128
+ /** `Agent` 的窄视图。 */
129
+ interface AgentLike {
130
+ readonly id: string;
131
+ readonly session: {
132
+ readonly header: SessionHeaderLike;
133
+ };
134
+ readonly status: string;
135
+ steer(message: unknown): void;
136
+ }
137
+ /** `ctx.agents`(AgentRegistry)的窄视图。 */
138
+ interface AgentsLike {
139
+ get(id: string): AgentLike | undefined;
140
+ list(): readonly AgentLike[];
141
+ }
142
+ /**
143
+ * 从插件上下文取 Agent 注册表。
144
+ * @param ctx - 插件上下文(只用到 `get`)。
145
+ * @returns 注册表,未挂载时为 undefined。
146
+ */
147
+ declare function agentsFrom(ctx: unknown): AgentsLike | undefined;
148
+ /**
149
+ * 是否 root 会话:无父会话且委派深度为 0。
150
+ * @param agent - 任意 live Agent。
151
+ * @returns root 时为 true。
152
+ */
153
+ declare function isRootSession(agent: AgentLike): boolean;
154
+ /**
155
+ * 两个会话是否同一工作区(cwd 解析后按平台大小写规则比较)。
156
+ * @param sender - 发送方 Agent。
157
+ * @param target - 目标 Agent。
158
+ * @returns 双方都记录过 cwd 且语义相同才为 true。
159
+ */
160
+ declare function sameWorkspace(sender: AgentLike, target: AgentLike): boolean;
161
+ /**
162
+ * 归一化 cwd 用于比较:`path.resolve` + Windows 下大小写不敏感。
163
+ * @param cwd - 会话记录的绝对路径。
164
+ * @returns 可比较的字符串;缺失/非法时为 undefined。
165
+ */
166
+ declare function normalizeCwd(cwd: string | undefined): string | undefined;
167
+ /**
168
+ * 归一化用户粘贴的会话 id:去空白、去包裹的引号/反引号/尖括号/中文引号。
169
+ *
170
+ * 来源场景:GUI 会话列表的「复制会话 ID」写的是原始 id(`session-<uuid>`),
171
+ * 用户把它贴进 prompt 时经常带引号、反引号或首尾空格,这里容忍这些噪声。
172
+ * @param raw - 模型/用户给出的目标字符串。
173
+ * @returns 用于查找的 id(可能为空串)。
174
+ */
175
+ declare function normalizeSessionIdInput(raw: string): string;
176
+ /**
177
+ * 按 id 找 live Agent;先精确匹配,再按 `session-` 前缀等价匹配。
178
+ * @param agents - Agent 注册表。
179
+ * @param target - 模型给出的会话 id。
180
+ * @returns 匹配的 Agent,找不到时为 undefined。
181
+ */
182
+ declare function resolveTargetAgent(agents: AgentsLike, target: string): AgentLike | undefined;
183
+ /**
184
+ * id 的短形式,仅用于展示;完整 id 仍随结果返回。
185
+ * @param id - session id。
186
+ * @returns 最后 8 个字符(uuid 片段)。
187
+ */
188
+ declare function shortSessionId(id: string): string;
189
+ //#endregion
190
+ //#region src/tool.d.ts
191
+ /** `session_send` 的返回值。 */
192
+ interface SessionSendValue {
193
+ readonly delivered: boolean;
194
+ readonly messageId: string;
195
+ readonly targetSessionId: string;
196
+ readonly targetShortId: string;
197
+ readonly targetStatus: string;
198
+ }
199
+ /** `session_peers` 的返回值。 */
200
+ interface SessionPeersValue {
201
+ readonly count: number;
202
+ readonly peers: readonly SessionPeerValue[];
203
+ }
204
+ /** `session_peers` 的一行。 */
205
+ interface SessionPeerValue {
206
+ readonly sessionId: string;
207
+ readonly shortId: string;
208
+ readonly status: string;
209
+ readonly root: boolean;
210
+ readonly self: boolean;
211
+ readonly sameWorkspace: boolean;
212
+ readonly canReceive: boolean;
213
+ readonly cwd?: string;
214
+ }
215
+ /**
216
+ * 创建 `session_send` 工具定义。
217
+ * @param provide - 延迟解析的 Agent 注册表。
218
+ * @param config - 已归一化配置。
219
+ * @param limiter - 每对会话的限流器。
220
+ * @param now - 时钟(测试可注入)。
221
+ * @returns 可直接交给 `ctx.tools.register` 的定义。
222
+ */
223
+ declare function createSessionSendTool(provide: () => AgentsLike | undefined, config: ResolvedSessionChatConfig, limiter: PairRateLimiter, now?: () => number): ToolDefinition;
224
+ /**
225
+ * 创建 `session_peers` 工具定义。
226
+ * @param provide - 延迟解析的 Agent 注册表。
227
+ * @param config - 已归一化配置。
228
+ * @returns 可直接交给 `ctx.tools.register` 的定义。
229
+ */
230
+ declare function createSessionPeersTool(provide: () => AgentsLike | undefined, config: ResolvedSessionChatConfig): ToolDefinition;
231
+ //#endregion
232
+ //#region src/index.d.ts
233
+ /** 插件服务名(cordis.patch.yml 里的 id 同此;与 dsh-token-guard / dsh-read-source 一致,用包名去掉 dsh- 前缀)。 */
234
+ declare const name = "session-chat";
235
+ /**
236
+ * 只静态依赖 `tools`(注册工具必需)。
237
+ * `agents` 刻意不写进静态 inject:静态 inject 在服务缺失时会让插件一直等待;
238
+ * 改为执行期 `ctx.get('agents')` 探测,缺失时工具给出 SESSION_CHAT_UNAVAILABLE。
239
+ */
240
+ declare const inject: string[];
241
+ /**
242
+ * 插件入口:注册 `session_peers` 与 `session_send`。
243
+ *
244
+ * **启动安全**:apply 全程 try/catch,任何失败只让插件保持惰性(打印一条 warning),
245
+ * 绝不让 `plugin tree failed to load` 把整个应用拖进致命对话框;
246
+ * 单个工具注册失败也只禁用该工具。`config.disabled` 是无需删包的 kill switch。
247
+ * @param ctx - 插件上下文。
248
+ * @param config - bundle patch 注入的配置(全部可选)。
249
+ */
250
+ declare function apply(ctx: Context, config?: SessionChatConfig): void;
251
+ //#endregion
252
+ export { PairRateLimiter, type ResolvedSessionChatConfig, type SessionChatConfig, type SessionPeerValue, type SessionPeersValue, type SessionSendValue, agentsFrom, apply, buildSessionChatMessage, createSessionPeersTool, createSessionSendTool, deepFreeze, inject, isRootSession, name, normalizeCwd, normalizeSessionIdInput, resolveSessionChatConfig, resolveTargetAgent, sameWorkspace, shortSessionId, utf8Bytes };
package/lib/index.js ADDED
@@ -0,0 +1,575 @@
1
+ import { resolve } from "node:path";
2
+ import { platform } from "node:os";
3
+ import { randomUUID } from "node:crypto";
4
+ //#region src/agents.ts
5
+ /**
6
+ * 宿主 Agent 接缝的**窄接口**:插件运行时零 `@deepseek-ai/*` value import,
7
+ * 只按结构化类型使用 `ctx.get('agents')` 返回的注册表。
8
+ *
9
+ * 这里的字段全部来自 DSH 的公开约定:
10
+ * - `Agent.id`:会话 id(`SessionId`,运行时就是字符串);
11
+ * - `Agent.session.header`:`cwd` / `parentSession` / `delegationDepth`;
12
+ * - `Agent.status`:'running' | 'idle' | …;
13
+ * - `Agent.steer(message)`:运行中在最近 step 边界注入,空闲则开一轮。
14
+ */
15
+ /**
16
+ * 从插件上下文取 Agent 注册表。
17
+ * @param ctx - 插件上下文(只用到 `get`)。
18
+ * @returns 注册表,未挂载时为 undefined。
19
+ */
20
+ function agentsFrom(ctx) {
21
+ const get = ctx?.get;
22
+ if (typeof get !== "function") return void 0;
23
+ const agents = get.call(ctx, "agents");
24
+ if (agents === void 0 || agents === null) return void 0;
25
+ const registry = agents;
26
+ if (typeof registry.get !== "function" || typeof registry.list !== "function") return void 0;
27
+ return agents;
28
+ }
29
+ /**
30
+ * 是否 root 会话:无父会话且委派深度为 0。
31
+ * @param agent - 任意 live Agent。
32
+ * @returns root 时为 true。
33
+ */
34
+ function isRootSession(agent) {
35
+ const header = agent.session.header;
36
+ return (header.delegationDepth ?? 0) === 0 && header.parentSession === void 0;
37
+ }
38
+ /**
39
+ * 两个会话是否同一工作区(cwd 解析后按平台大小写规则比较)。
40
+ * @param sender - 发送方 Agent。
41
+ * @param target - 目标 Agent。
42
+ * @returns 双方都记录过 cwd 且语义相同才为 true。
43
+ */
44
+ function sameWorkspace(sender, target) {
45
+ const left = normalizeCwd(sender.session.header.cwd);
46
+ const right = normalizeCwd(target.session.header.cwd);
47
+ return left !== void 0 && right !== void 0 && left === right;
48
+ }
49
+ /**
50
+ * 归一化 cwd 用于比较:`path.resolve` + Windows 下大小写不敏感。
51
+ * @param cwd - 会话记录的绝对路径。
52
+ * @returns 可比较的字符串;缺失/非法时为 undefined。
53
+ */
54
+ function normalizeCwd(cwd) {
55
+ if (typeof cwd !== "string" || cwd.trim().length === 0) return void 0;
56
+ try {
57
+ const resolved = resolve(cwd);
58
+ return platform() === "win32" ? resolved.toLowerCase() : resolved;
59
+ } catch {
60
+ return;
61
+ }
62
+ }
63
+ /** `session-<uuid>` 与 `<uuid>` 视为同一 id。 */
64
+ function bareSessionId(id) {
65
+ return id.startsWith("session-") ? id.slice(8) : id;
66
+ }
67
+ /**
68
+ * 归一化用户粘贴的会话 id:去空白、去包裹的引号/反引号/尖括号/中文引号。
69
+ *
70
+ * 来源场景:GUI 会话列表的「复制会话 ID」写的是原始 id(`session-<uuid>`),
71
+ * 用户把它贴进 prompt 时经常带引号、反引号或首尾空格,这里容忍这些噪声。
72
+ * @param raw - 模型/用户给出的目标字符串。
73
+ * @returns 用于查找的 id(可能为空串)。
74
+ */
75
+ function normalizeSessionIdInput(raw) {
76
+ return raw.trim().replace(/^["'`<\[((「『]+/, "").replace(/["'`>\]))」』]+$/, "").trim();
77
+ }
78
+ /**
79
+ * 按 id 找 live Agent;先精确匹配,再按 `session-` 前缀等价匹配。
80
+ * @param agents - Agent 注册表。
81
+ * @param target - 模型给出的会话 id。
82
+ * @returns 匹配的 Agent,找不到时为 undefined。
83
+ */
84
+ function resolveTargetAgent(agents, target) {
85
+ const exact = agents.get(target);
86
+ if (exact !== void 0) return exact;
87
+ const wanted = bareSessionId(target);
88
+ for (const agent of agents.list()) if (bareSessionId(agent.id) === wanted) return agent;
89
+ }
90
+ /**
91
+ * id 的短形式,仅用于展示;完整 id 仍随结果返回。
92
+ * @param id - session id。
93
+ * @returns 最后 8 个字符(uuid 片段)。
94
+ */
95
+ function shortSessionId(id) {
96
+ return bareSessionId(id).slice(-8);
97
+ }
98
+ //#endregion
99
+ //#region src/config.ts
100
+ const DEFAULT_MAX_MESSAGE_BYTES = 8192;
101
+ const DEFAULT_MAX_MESSAGES_PER_WINDOW = 30;
102
+ const DEFAULT_WINDOW_MS = 6e5;
103
+ const MAX_MESSAGE_BYTES_CEILING = 65536;
104
+ const MAX_MESSAGES_CEILING = 1e3;
105
+ const WINDOW_MS_CEILING = 864e5;
106
+ function positiveInteger(value, fallback, ceiling) {
107
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
108
+ const bounded = Math.min(Math.floor(value), ceiling);
109
+ return bounded > 0 ? bounded : fallback;
110
+ }
111
+ /**
112
+ * 归一化配置:类型不对、越界、空值都回落默认值,不抛错。
113
+ * @param input - loader 注入的原始配置(可能为 undefined)。
114
+ * @returns 可直接使用的配置。
115
+ */
116
+ function resolveSessionChatConfig(input = {}) {
117
+ const record = typeof input === "object" && input !== null ? input : {};
118
+ const allowlist = Array.isArray(record.allowlist) ? record.allowlist.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter((entry) => entry.length > 0) : [];
119
+ return {
120
+ disabled: record.disabled === true,
121
+ allowlist,
122
+ maxMessageBytes: positiveInteger(record.maxMessageBytes, DEFAULT_MAX_MESSAGE_BYTES, MAX_MESSAGE_BYTES_CEILING),
123
+ maxMessagesPerWindow: positiveInteger(record.maxMessagesPerWindow, DEFAULT_MAX_MESSAGES_PER_WINDOW, MAX_MESSAGES_CEILING),
124
+ windowMs: positiveInteger(record.windowMs, DEFAULT_WINDOW_MS, WINDOW_MS_CEILING),
125
+ allowNonRootSessions: record.allowNonRootSessions === true
126
+ };
127
+ }
128
+ //#endregion
129
+ //#region src/limits.ts
130
+ /** 进程内滑动窗口限流器(不持久化;应用重启即清零,符合 MVP 预期)。 */
131
+ var PairRateLimiter = class {
132
+ maxPerWindow;
133
+ windowMs;
134
+ stamps = /* @__PURE__ */ new Map();
135
+ /**
136
+ * @param maxPerWindow - 窗口内允许的消息条数(>=1)。
137
+ * @param windowMs - 窗口长度(毫秒,>=1)。
138
+ */
139
+ constructor(maxPerWindow, windowMs) {
140
+ this.maxPerWindow = maxPerWindow;
141
+ this.windowMs = windowMs;
142
+ }
143
+ /**
144
+ * 尝试占用一个配额。只有真正要投递时才调用(参数校验之后再调用,避免坏消息吃掉配额)。
145
+ * @param sender - 发送方会话 id。
146
+ * @param target - 目标会话 id。
147
+ * @param now - 当前时间戳(毫秒,测试可注入)。
148
+ * @returns 放行,或建议的重试等待时间。
149
+ */
150
+ check(sender, target, now) {
151
+ const key = `${sender}\u0000${target}`;
152
+ const cutoff = now - this.windowMs;
153
+ const kept = (this.stamps.get(key) ?? []).filter((at) => at > cutoff);
154
+ if (kept.length >= this.maxPerWindow) {
155
+ this.stamps.set(key, kept);
156
+ const oldest = kept[0] ?? now;
157
+ return {
158
+ ok: false,
159
+ retryAfterMs: Math.max(1, oldest + this.windowMs - now)
160
+ };
161
+ }
162
+ kept.push(now);
163
+ this.stamps.set(key, kept);
164
+ return { ok: true };
165
+ }
166
+ /** 清空状态(测试用)。 */
167
+ reset() {
168
+ this.stamps.clear();
169
+ }
170
+ };
171
+ //#endregion
172
+ //#region src/message.ts
173
+ /**
174
+ * 构造“跨会话消息”的 durable 表达。
175
+ *
176
+ * 形状照宿主自己的 `createAgentMessage()`(subagent 包)来,保证三处一致:
177
+ * - `role: 'user'`:接收方按普通用户消息处理(会触发/续接它的 turn);
178
+ * - `source: { kind: 'agent-message', form: 'relay', senderSessionId }`:session 格式 v3
179
+ * 已登记该来源,持久化/回放/UI 都能识别(UI 会显示 agent 图标与 “message from agent” 标题);
180
+ * - 首块是 `Agent <id> sent a message: ` 前缀,把发送者身份写进模型可见文本。
181
+ *
182
+ * 这里是**手写**而不是 import 宿主函数:插件运行时零 `@deepseek-ai/*` value import
183
+ * (宿主包解析属于插件不该依赖的细节),字段形状与宿主实现逐字对齐即可。
184
+ */
185
+ /**
186
+ * 构造一条投递给目标会话的用户消息。
187
+ * @param senderSessionId - 发送方会话 id(写进 source 与文本前缀)。
188
+ * @param text - 发送方给出的正文。
189
+ * @returns 已深冻结的消息对象。
190
+ */
191
+ function buildSessionChatMessage(senderSessionId, text) {
192
+ return deepFreeze({
193
+ id: randomUUID(),
194
+ role: "user",
195
+ content: [{
196
+ type: "text",
197
+ text: `Agent ${senderSessionId} sent a message: `
198
+ }, {
199
+ type: "text",
200
+ text
201
+ }],
202
+ source: {
203
+ kind: "agent-message",
204
+ form: "relay",
205
+ senderSessionId
206
+ }
207
+ });
208
+ }
209
+ /**
210
+ * 递归冻结(消息进入 inbox 前应与宿主 `createMessage` 一样不可变)。
211
+ * @param value - 任意对象。
212
+ * @returns 同一个对象。
213
+ */
214
+ function deepFreeze(value) {
215
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
216
+ Object.freeze(value);
217
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
218
+ }
219
+ return value;
220
+ }
221
+ /**
222
+ * 计算文本的 UTF-8 字节数。
223
+ * @param text - 任意文本。
224
+ * @returns 字节数。
225
+ */
226
+ function utf8Bytes(text) {
227
+ return Buffer.byteLength(text, "utf8");
228
+ }
229
+ //#endregion
230
+ //#region src/tool.ts
231
+ const TOOL_SEND = "session_send";
232
+ const TOOL_PEERS = "session_peers";
233
+ const SEND_DESCRIPTION = [
234
+ "Send one short plain-text message to another live session of this DeepSeek Harness host.",
235
+ "Prefer the exact session id the user gave you: the session list row menu has \"Copy session ID\", and that",
236
+ "pasted id is authoritative — pass it straight through. Call session_peers only when you must discover an id",
237
+ "or verify that the target is live.",
238
+ "Use it only for conversation/negotiation that a human would otherwise relay by hand (e.g. two sessions",
239
+ "editing the same repository must agree on file ownership or merge order).",
240
+ "Delivery is asynchronous: a running target receives the message at its next step boundary, an idle target",
241
+ "starts a new turn. The returned receipt means \"accepted into the target inbox\", never \"the target replied\".",
242
+ "This tool does not read or write any file and does not grant tools or permissions.",
243
+ "Targets must be live root sessions in the same working directory as you. To reply, send to the session id",
244
+ "shown in the incoming message prefix (\"Agent <id> sent a message\").",
245
+ "Keep exchanges short: every message costs the target a full model step, and repeated ping-pong is rate",
246
+ "limited. For structured delegation use subagents or Agent Teams instead."
247
+ ].join(" ");
248
+ const PEERS_DESCRIPTION = [
249
+ "Optional discovery helper: list the live sessions of this DeepSeek Harness host that you may talk to with",
250
+ "session_send. Skip it when the user already pasted a session id — that id is authoritative.",
251
+ "Only sessions in the same working directory are listed. Each row reports its session id, run status,",
252
+ "whether it is a root session, and whether it can currently receive a message (canReceive).",
253
+ "A session that is not listed has never been opened in this host or belongs to another workspace; it cannot",
254
+ "be messaged, because this tool never cold-starts a session."
255
+ ].join(" ");
256
+ const SEND_PARAMETERS = {
257
+ type: "object",
258
+ additionalProperties: false,
259
+ properties: {
260
+ target: {
261
+ type: "string",
262
+ description: "Session id copied from the session list (\"session-<uuid>\" or the bare uuid). Surrounding quotes, backticks, and whitespace are tolerated."
263
+ },
264
+ message: {
265
+ type: "string",
266
+ description: "Plain UTF-8 text to deliver. Be concise; this becomes one user-role message in the target session."
267
+ }
268
+ },
269
+ required: ["target", "message"]
270
+ };
271
+ const SEND_OUTPUT_SCHEMA = {
272
+ type: "object",
273
+ additionalProperties: false,
274
+ properties: {
275
+ delivered: { type: "boolean" },
276
+ messageId: { type: "string" },
277
+ targetSessionId: { type: "string" },
278
+ targetShortId: { type: "string" },
279
+ targetStatus: { type: "string" }
280
+ },
281
+ required: [
282
+ "delivered",
283
+ "messageId",
284
+ "targetSessionId",
285
+ "targetShortId",
286
+ "targetStatus"
287
+ ]
288
+ };
289
+ const PEERS_PARAMETERS = {
290
+ type: "object",
291
+ additionalProperties: false,
292
+ properties: { include_self: {
293
+ type: "boolean",
294
+ description: "When true, include your own session in the list (default false)."
295
+ } }
296
+ };
297
+ const PEERS_OUTPUT_SCHEMA = {
298
+ type: "object",
299
+ additionalProperties: false,
300
+ properties: {
301
+ count: { type: "integer" },
302
+ peers: {
303
+ type: "array",
304
+ items: {
305
+ type: "object",
306
+ additionalProperties: false,
307
+ properties: {
308
+ sessionId: { type: "string" },
309
+ shortId: { type: "string" },
310
+ status: { type: "string" },
311
+ root: { type: "boolean" },
312
+ self: { type: "boolean" },
313
+ sameWorkspace: { type: "boolean" },
314
+ canReceive: { type: "boolean" },
315
+ cwd: { type: "string" }
316
+ },
317
+ required: [
318
+ "sessionId",
319
+ "shortId",
320
+ "status",
321
+ "root",
322
+ "self",
323
+ "sameWorkspace",
324
+ "canReceive"
325
+ ]
326
+ }
327
+ }
328
+ },
329
+ required: ["count", "peers"]
330
+ };
331
+ /**
332
+ * 取 Agent 注册表,缺失时给可判读的报错。
333
+ * @param provide - 延迟解析的注册表提供者。
334
+ * @returns 注册表。
335
+ */
336
+ function requireAgents(provide) {
337
+ const agents = provide();
338
+ if (agents === void 0) throw new Error("SESSION_CHAT_UNAVAILABLE: the agents service is not mounted; session chat needs the host Agent registry");
339
+ return agents;
340
+ }
341
+ /**
342
+ * 取调用方 Agent,缺失时报错(工具只能在 agent turn 内调用)。
343
+ * @param exec - 工具执行上下文。
344
+ * @returns 调用方 Agent。
345
+ */
346
+ function requireCaller(exec) {
347
+ const agent = exec?.agent;
348
+ if (agent === void 0 || agent === null) throw new Error("SESSION_CHAT_NO_CALLER: this tool must be called from inside an agent turn");
349
+ return agent;
350
+ }
351
+ /**
352
+ * root 会话校验(默认不允许 subagent/teammate 子会话参与)。
353
+ * @param agent - 待校验 Agent。
354
+ * @param role - 报错文案里的角色(caller / target)。
355
+ * @param config - 已归一化配置。
356
+ */
357
+ function assertRootSession(agent, role, config) {
358
+ if (config.allowNonRootSessions) return;
359
+ if (!isRootSession(agent)) throw new Error(`SESSION_CHAT_NOT_ROOT: the ${role} session (${shortSessionId(agent.id)}) is not a root session; nested subagent/teammate sessions use their own delivery path`);
360
+ }
361
+ /**
362
+ * 同工作区校验。
363
+ * @param caller - 发送方。
364
+ * @param target - 目标。
365
+ */
366
+ function assertSameWorkspace(caller, target) {
367
+ if (!sameWorkspace(caller, target)) {
368
+ const from = caller.session.header.cwd ?? "(no cwd)";
369
+ const to = target.session.header.cwd ?? "(no cwd)";
370
+ throw new Error(`SESSION_CHAT_CROSS_WORKSPACE: cross-workspace messaging is not allowed (${from} -> ${to})`);
371
+ }
372
+ }
373
+ /**
374
+ * 白名单校验(白名单为空 = 只限制同工作区 root)。
375
+ * @param targetId - 目标会话 id。
376
+ * @param config - 已归一化配置。
377
+ */
378
+ function assertAllowlisted(targetId, config) {
379
+ if (config.allowlist.length === 0) return;
380
+ const bare = targetId.startsWith("session-") ? targetId.slice(8) : targetId;
381
+ if (!config.allowlist.some((entry) => {
382
+ return (entry.startsWith("session-") ? entry.slice(8) : entry) === bare;
383
+ })) throw new Error(`SESSION_CHAT_NOT_ALLOWED: target session ${shortSessionId(targetId)} is not in the configured allowlist`);
384
+ }
385
+ /**
386
+ * 判断目标能否收消息(只做静态校验,不触发限流)。
387
+ * @param caller - 发送方。
388
+ * @param candidate - 候选目标。
389
+ * @param config - 已归一化配置。
390
+ * @returns 可收时为 true。
391
+ */
392
+ function canReceiveFrom(caller, candidate, config) {
393
+ if (candidate.id === caller.id) return false;
394
+ if (!config.allowNonRootSessions && !isRootSession(candidate)) return false;
395
+ if (!sameWorkspace(caller, candidate)) return false;
396
+ try {
397
+ assertAllowlisted(candidate.id, config);
398
+ } catch {
399
+ return false;
400
+ }
401
+ return true;
402
+ }
403
+ /**
404
+ * 创建 `session_send` 工具定义。
405
+ * @param provide - 延迟解析的 Agent 注册表。
406
+ * @param config - 已归一化配置。
407
+ * @param limiter - 每对会话的限流器。
408
+ * @param now - 时钟(测试可注入)。
409
+ * @returns 可直接交给 `ctx.tools.register` 的定义。
410
+ */
411
+ function createSessionSendTool(provide, config, limiter, now = () => Date.now()) {
412
+ return {
413
+ name: TOOL_SEND,
414
+ description: SEND_DESCRIPTION,
415
+ parameters: SEND_PARAMETERS,
416
+ isConcurrencySafe: () => true,
417
+ output: {
418
+ schema: SEND_OUTPUT_SCHEMA,
419
+ render(_args, value) {
420
+ const record = typeof value === "object" && value !== null ? value : {};
421
+ return [{
422
+ type: "text",
423
+ text: `Delivered to session ${record.targetShortId ?? "?"} (${record.targetSessionId ?? "?"}) · message ${record.messageId ?? "?"} · target status: ${record.targetStatus ?? "?"}. Delivery is not a reply.`
424
+ }];
425
+ }
426
+ },
427
+ async execute(args, exec) {
428
+ const caller = requireCaller(exec);
429
+ const agents = requireAgents(provide);
430
+ const request = typeof args === "object" && args !== null ? args : {};
431
+ const targetId = typeof request.target === "string" ? normalizeSessionIdInput(request.target) : "";
432
+ const text = typeof request.message === "string" ? request.message : "";
433
+ if (targetId.length === 0) throw new Error("SESSION_CHAT_INVALID_TARGET: target must be a non-empty session id");
434
+ if (text.trim().length === 0) throw new Error("SESSION_CHAT_EMPTY_MESSAGE: message must not be empty");
435
+ const target = resolveTargetAgent(agents, targetId);
436
+ if (target === void 0) throw new Error(`SESSION_CHAT_UNKNOWN_TARGET: no live session "${targetId}" in this host; call session_peers to list live sessions (a session must be open to receive messages)`);
437
+ if (target.id === caller.id) throw new Error("SESSION_CHAT_SELF: a session cannot message itself");
438
+ assertRootSession(caller, "calling", config);
439
+ assertRootSession(target, "target", config);
440
+ assertSameWorkspace(caller, target);
441
+ assertAllowlisted(target.id, config);
442
+ const bytes = utf8Bytes(text);
443
+ if (bytes > config.maxMessageBytes) throw new Error(`SESSION_CHAT_TOO_LARGE: message is ${bytes} UTF-8 bytes, limit is ${config.maxMessageBytes}; send a shorter summary and put long content in a file both sessions can read`);
444
+ const verdict = limiter.check(caller.id, target.id, now());
445
+ if (!verdict.ok) throw new Error(`SESSION_CHAT_RATE_LIMITED: this session pair reached ${config.maxMessagesPerWindow} messages in ${Math.round(config.windowMs / 1e3)}s; retry in ~${Math.ceil(verdict.retryAfterMs / 1e3)}s or stop the conversation and hand the conclusion to the user`);
446
+ const message = buildSessionChatMessage(caller.id, text);
447
+ try {
448
+ target.steer(message);
449
+ } catch (error) {
450
+ throw new Error(`SESSION_CHAT_DELIVERY_FAILED: target inbox rejected the message (${error instanceof Error ? error.message : String(error)})`);
451
+ }
452
+ return {
453
+ delivered: true,
454
+ messageId: message.id,
455
+ targetSessionId: target.id,
456
+ targetShortId: shortSessionId(target.id),
457
+ targetStatus: target.status
458
+ };
459
+ }
460
+ };
461
+ }
462
+ /**
463
+ * 创建 `session_peers` 工具定义。
464
+ * @param provide - 延迟解析的 Agent 注册表。
465
+ * @param config - 已归一化配置。
466
+ * @returns 可直接交给 `ctx.tools.register` 的定义。
467
+ */
468
+ function createSessionPeersTool(provide, config) {
469
+ return {
470
+ name: TOOL_PEERS,
471
+ description: PEERS_DESCRIPTION,
472
+ parameters: PEERS_PARAMETERS,
473
+ isConcurrencySafe: () => true,
474
+ output: {
475
+ schema: PEERS_OUTPUT_SCHEMA,
476
+ render(_args, value) {
477
+ const record = typeof value === "object" && value !== null ? value : {};
478
+ const peers = Array.isArray(record.peers) ? record.peers : [];
479
+ if (peers.length === 0) return [{
480
+ type: "text",
481
+ text: "No peer sessions in this workspace can receive messages."
482
+ }];
483
+ return [{
484
+ type: "text",
485
+ text: `Live sessions you may message:\n${peers.map((peer) => {
486
+ const flags = [
487
+ peer.root ? "root" : "nested",
488
+ peer.self ? "self" : "",
489
+ peer.sameWorkspace ? "same-workspace" : "other-workspace",
490
+ peer.canReceive ? "can-receive" : "cannot-receive"
491
+ ].filter((flag) => flag.length > 0).join(", ");
492
+ return `- ${peer.sessionId} (${peer.shortId}) · ${peer.status} · ${flags}`;
493
+ }).join("\n")}`
494
+ }];
495
+ }
496
+ },
497
+ async execute(args, exec) {
498
+ const caller = requireCaller(exec);
499
+ const agents = requireAgents(provide);
500
+ const includeSelf = (typeof args === "object" && args !== null ? args : {}).include_self === true;
501
+ const peers = [];
502
+ for (const agent of agents.list()) {
503
+ const self = agent.id === caller.id;
504
+ if (self && !includeSelf) continue;
505
+ if (!sameWorkspace(caller, agent)) continue;
506
+ const row = {
507
+ sessionId: agent.id,
508
+ shortId: shortSessionId(agent.id),
509
+ status: agent.status,
510
+ root: isRootSession(agent),
511
+ self,
512
+ sameWorkspace: true,
513
+ canReceive: canReceiveFrom(caller, agent, config),
514
+ ...agent.session.header.cwd === void 0 ? {} : { cwd: agent.session.header.cwd }
515
+ };
516
+ peers.push(row);
517
+ }
518
+ return {
519
+ count: peers.length,
520
+ peers
521
+ };
522
+ }
523
+ };
524
+ }
525
+ //#endregion
526
+ //#region src/index.ts
527
+ /** 插件服务名(cordis.patch.yml 里的 id 同此;与 dsh-token-guard / dsh-read-source 一致,用包名去掉 dsh- 前缀)。 */
528
+ const name = "session-chat";
529
+ /**
530
+ * 只静态依赖 `tools`(注册工具必需)。
531
+ * `agents` 刻意不写进静态 inject:静态 inject 在服务缺失时会让插件一直等待;
532
+ * 改为执行期 `ctx.get('agents')` 探测,缺失时工具给出 SESSION_CHAT_UNAVAILABLE。
533
+ */
534
+ const inject = ["tools"];
535
+ /**
536
+ * 插件入口:注册 `session_peers` 与 `session_send`。
537
+ *
538
+ * **启动安全**:apply 全程 try/catch,任何失败只让插件保持惰性(打印一条 warning),
539
+ * 绝不让 `plugin tree failed to load` 把整个应用拖进致命对话框;
540
+ * 单个工具注册失败也只禁用该工具。`config.disabled` 是无需删包的 kill switch。
541
+ * @param ctx - 插件上下文。
542
+ * @param config - bundle patch 注入的配置(全部可选)。
543
+ */
544
+ function apply(ctx, config = {}) {
545
+ try {
546
+ const resolved = resolveSessionChatConfig(config);
547
+ if (resolved.disabled) {
548
+ report(ctx, "disabled by config; tools not registered");
549
+ return;
550
+ }
551
+ const limiter = new PairRateLimiter(resolved.maxMessagesPerWindow, resolved.windowMs);
552
+ const provideAgents = () => agentsFrom(ctx);
553
+ registerSafely(ctx, createSessionPeersTool(provideAgents, resolved), "session_peers");
554
+ registerSafely(ctx, createSessionSendTool(provideAgents, resolved, limiter), "session_send");
555
+ } catch (error) {
556
+ report(ctx, "failed to install; plugin stays inert", error);
557
+ }
558
+ }
559
+ /** 取宿主 logger(cordis 的 ctx.logger),没有就退回 console。 */
560
+ function report(ctx, message, error) {
561
+ const text = `[session-chat] ${error === void 0 ? message : `${message}: ${error instanceof Error ? error.message : String(error)}`}`;
562
+ const logger = ctx.logger;
563
+ if (typeof logger?.warn === "function") logger.warn(text);
564
+ else console.warn(text);
565
+ }
566
+ /** 注册一个工具;失败只禁用该工具(同名冲突等),不冒泡到 boot。 */
567
+ function registerSafely(ctx, definition, name) {
568
+ try {
569
+ ctx.tools.register(definition);
570
+ } catch (error) {
571
+ report(ctx, `failed to register tool "${name}"; that tool is unavailable`, error);
572
+ }
573
+ }
574
+ //#endregion
575
+ export { PairRateLimiter, agentsFrom, apply, buildSessionChatMessage, createSessionPeersTool, createSessionSendTool, deepFreeze, inject, isRootSession, name, normalizeCwd, normalizeSessionIdInput, resolveSessionChatConfig, resolveTargetAgent, sameWorkspace, shortSessionId, utf8Bytes };
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
- {
2
- "name": "dsh-session-chat",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "dsh-session-chat",
3
+ "version": "0.1.0",
4
+ "description": "[EXPERIMENTAL] Plain-text chat between two independent DSH sessions: a host tool that delivers an agent message into another live session's inbox, with same-workspace, root-only, rate-limited delivery.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "cordis.patch.yml",
15
+ "README.md"
16
+ ],
17
+ "license": "MIT",
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "dsh": {
22
+ "displayName": "Session Chat",
23
+ "description": "两个独立会话之间的纯文本对话通道(不碰文件,带同工作区/root/限流护栏)",
24
+ "bundle": {
25
+ "patch": "./cordis.patch.yml"
26
+ }
27
+ },
28
+ "keywords": [
29
+ "dsh",
30
+ "deepseek-harness",
31
+ "plugin",
32
+ "cordis",
33
+ "session",
34
+ "chat"
35
+ ],
36
+ "scripts": {
37
+ "build": "tsdown",
38
+ "typecheck": "tsc -p tsconfig.typecheck.json",
39
+ "probe": "node scripts/probe.mjs",
40
+ "verify": "node scripts/validate-schemas.mjs && node scripts/probe.mjs",
41
+ "gen-paths": "node scripts/gen-typecheck-paths.mjs"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^24.19.2",
45
+ "tsdown": "^0.22.14",
46
+ "typescript": "^5.9.3"
47
+ }
48
+ }