dsh-session-messaging 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.4.0] - 2026-10-18
8
+
9
+ ### Added
10
+
11
+ - Cold-session delivery: when a target session is not live, the plugin resumes
12
+ it through the deployment's `sessionController` before delivering. Enable or
13
+ disable it with the new `allowColdDelivery` config field (on by default).
14
+ - `session_send` receipts now report `delivery: 'live' | 'resumed'`.
15
+ - New `cold-delivery-failed` error code for a refused or failed resume.
16
+
17
+ ### Changed
18
+
19
+ - Profiles without a session controller keep the previous `target-not-live`
20
+ refusal; archived/subagent-owned sessions are refused by the controller.
21
+
22
+ ## [0.3.0] - 2026-10-18
23
+
24
+ ### Added
25
+
26
+ - A client-side sidebar panel, “Session Messaging”, with:
27
+ - a cross-directory toggle persisted through the dsh settings form
28
+ (`sameDirectoryOnly` is now a volatile config field);
29
+ - an npm update check against the published package;
30
+ - links to npm, GitHub, and the author's blog.
31
+
32
+ ### Changed
33
+
34
+ - The `sameDirectoryOnly` config field is volatile, so the panel can edit it
35
+ at runtime without editing the profile patch by hand.
36
+
7
37
  ## [0.2.0] - 2026-10-18
8
38
 
9
39
  ### Changed
package/README.md CHANGED
@@ -40,16 +40,24 @@ Two user-launched sessions used the plugin to coordinate a real fix. One session
40
40
 
41
41
  See [CHANGELOG.md](CHANGELOG.md) for the `0.1.2` entry.
42
42
 
43
+ ### Settings panel
44
+
45
+ ![Session Messaging settings panel](docs/assets/panel.webp)
46
+
47
+ The sidebar entry opens a small panel: toggle cross-directory messaging (persisted through the dsh settings form), check the latest npm version, and open npm, GitHub, or the author's blog. After installing or upgrading the bundle, refresh the Web UI once so the client half joins the module graph.
48
+
43
49
  ## Features
44
50
 
45
51
  - `session_peers` — list live, same-directory, top-level sessions and their reception state.
46
52
  - `session_send` — relay a coordination message, optionally declaring `writeScopes` and replying to a message.
47
53
  - `session_reception` — pause/resume receiving peer messages for the current session and declare its write scopes.
54
+ - **Sidebar panel** — a “Session Messaging” entry in the left sidebar with a cross-directory toggle, update check, and author links.
48
55
  - Messages appear inline in the target conversation, attributed to the sending session — no separate inbox UI.
49
56
  - Advisory **write-scope conflict detection**: if a peer declared an overlapping path, the receipt and the message envelope carry a `conflict-warning`.
50
57
  - No new Session event types and no `SESSION_FORMAT_VERSION` change: the relay is a durable `user/message` with a declared `session-message` source.
51
58
  - Bilingual plugin-card metadata and icons (English / 简体中文) for the dsh plugin manager.
52
- - Bounded: message size, per-sender→target rate limit, top-level-only, live-only, self-send rejected. Cross-directory peers are allowed by default; `sameDirectoryOnly: true` restricts to one working directory.
59
+ - **Cold-session delivery** — a persisted session that is not live is resumed through the Web/Desktop `sessionController` before delivery (`allowColdDelivery`, on by default).
60
+ - Bounded: message size, per-sender→target rate limit, top-level-only, self-send rejected. Cross-directory peers are allowed by default; `sameDirectoryOnly: true` restricts to one working directory.
53
61
 
54
62
  ## How it works
55
63
 
@@ -80,7 +88,7 @@ The envelope always states that the message comes from another session, not the
80
88
  dsh plugin --profile <profile> add dsh-session-messaging
81
89
  ```
82
90
 
83
- The package is published as [`dsh-session-messaging`](https://www.npmjs.com/package/dsh-session-messaging) (latest `0.2.0`). For library use: `npm install dsh-session-messaging`.
91
+ The package is published as [`dsh-session-messaging`](https://www.npmjs.com/package/dsh-session-messaging) (latest `0.4.0`). For library use: `npm install dsh-session-messaging`.
84
92
 
85
93
  ### From GitHub
86
94
 
@@ -96,6 +104,15 @@ The repository ships the built `lib/` output, so a git install does not run a bu
96
104
  dsh plugin --profile <profile> add /absolute/path/to/dsh-session-messaging
97
105
  ```
98
106
 
107
+ ### Troubleshooting: switching from a link install
108
+
109
+ If a profile previously linked a local checkout and later installed the npm package **while the app was running**, the running Loader can still reference the old checkout path. Removing that checkout's `node_modules` then surfaces `ERR_MODULE_NOT_FOUND` for `@deepseek-ai/*` peers even though the npm package is installed. Remove and reinstall the bundle, then restart the app (a page reload is enough for the client half):
110
+
111
+ ```sh
112
+ dsh plugin --profile <profile> remove dsh-session-messaging
113
+ dsh plugin --profile <profile> add dsh-session-messaging@latest
114
+ ```
115
+
99
116
  ### Desktop app
100
117
 
101
118
  The macOS/Windows desktop app uses its own profiles (for example `desktop`). Install with the bundled CLI or the agent's `plugin_manager` tool, targeting that profile. In dsh `0.2.1-rc.2` the configuration reloads through HMR, so the two plugin rows usually activate without restarting the app.
@@ -141,13 +158,14 @@ The bundle row accepts validated configuration; every field has a default:
141
158
  requireTargetOptIn: false # sessions are open to peers by default
142
159
  sameDirectoryOnly: false
143
160
  allowSelfSend: false
161
+ allowColdDelivery: true # resume a persisted target when it is not live
144
162
  ```
145
163
 
146
164
  ## Security model and limitations
147
165
 
148
166
  - **Peer content is untrusted input.** It enters the target session as a user message. The envelope labels the sender and warns the model, but dsh has no content-approval point for peer messages; normal tool approvals, `agent/pre-step`, and sandbox policy remain the only hard boundaries.
149
167
  - **Top-level sessions only, in both directions.** A subagent session is neither listed nor allowed to send (`caller-not-top-level`), and a top-level session cannot target a subagent (`target-not-top-level`). Subagents keep using the existing parent/child `send_message` channel. The envelope `from` field is the trusted identity; any identity claim in the message body is data only.
150
- - **Same process only.** Discovery uses `ctx.agents`; a session that is not live cannot receive a message in this version. Cold-session delivery and cross-process/cross-host messaging are not implemented.
168
+ - **Cold sessions are resumed, not queued.** When the target is not live and `allowColdDelivery` is on, the plugin calls the deployment's `sessionController.resolveAgent()`, which resumes that session and may start a model turn (token cost). Profiles without that controller still answer `target-not-live`. Archived or subagent-owned sessions are refused by the controller/session gate. Cross-process/cross-host messaging remains unimplemented.
151
169
  - **Advisory scopes, not locks.** dsh has optimistic filesystem version guards (`FS_STALE_VERSION`) but no file locking. `writeScopes` and `conflict-warning` are coordination aids; Bash, formatters, and code generators bypass them.
152
170
  - **In-memory state.** Reception pauses and declared scopes live for the plugin's lifetime and reset on reload/restart.
153
171
  - **Cross-directory by default.** Sessions in different working directories can message each other. Set `sameDirectoryOnly: true` to restore a path boundary; peer content remains untrusted either way.
package/README.zh-CN.md CHANGED
@@ -36,16 +36,24 @@ macOS 桌面客户端(`0.2.1-rc.2`)真实运行:另一个用户启动的
36
36
 
37
37
  `0.1.2` 的变更见 [CHANGELOG.md](CHANGELOG.md)。
38
38
 
39
+ ### 设置面板
40
+
41
+ ![跨会话消息设置面板](docs/assets/panel.webp)
42
+
43
+ 侧边栏入口打开一个设置面板:切换跨目录消息(通过 dsh 设置表单持久化)、检查 npm 最新版本,并打开 npm / GitHub / 作者博客。安装或升级 bundle 后刷新一次 Web UI,客户端模块才会进入模块图。
44
+
39
45
  ## 功能
40
46
 
41
47
  - `session_peers`:列出同目录、live 的顶层会话及其接收状态。
42
48
  - `session_send`:发送协调消息,可附带 `writeScopes` 与 `replyTo`。
43
49
  - `session_reception`:暂停/恢复本会话接收对等消息,并声明写范围。
50
+ - **侧边栏面板**:左侧“跨会话消息”入口,提供跨目录开关、版本更新检查与作者链接。
44
51
  - 消息直接内联显示在目标会话中,来源归属于发送会话,不需要独立收件箱。
45
52
  - 建议性**写范围冲突检测**:与 peer 声明重叠时,回执与消息信封带 `conflict-warning`。
46
53
  - 不新增 Session 事件类型、不改 `SESSION_FORMAT_VERSION`:消息就是带 `session-message` source 的 `user/message`。
47
54
  - 插件管理卡片提供中英双语标题、简介与图标(English / 简体中文)。
48
- - 有边界:长度上限、每 sender→target 限频、仅顶层、仅 live、拒绝自发送。默认允许跨目录对等会话;`sameDirectoryOnly: true` 可收紧为同工作目录。
55
+ - **冷会话投递**:目标不在 live 列表时,通过 Web/Desktop 的 `sessionController` 恢复持久会话后再投递(`allowColdDelivery`,默认开启)。
56
+ - 有边界:长度上限、每 sender→target 限频、仅顶层、拒绝自发送。默认允许跨目录对等会话;`sameDirectoryOnly: true` 可收紧为同工作目录。
49
57
 
50
58
  ## 工作原理
51
59
 
@@ -76,7 +84,7 @@ macOS 桌面客户端(`0.2.1-rc.2`)真实运行:另一个用户启动的
76
84
  dsh plugin --profile <profile> add dsh-session-messaging
77
85
  ```
78
86
 
79
- 包已发布为 [`dsh-session-messaging`](https://www.npmjs.com/package/dsh-session-messaging)(最新 `0.2.0`)。库用法:`npm install dsh-session-messaging`。
87
+ 包已发布为 [`dsh-session-messaging`](https://www.npmjs.com/package/dsh-session-messaging)(最新 `0.4.0`)。库用法:`npm install dsh-session-messaging`。
80
88
 
81
89
  ### 从 GitHub 安装
82
90
 
@@ -92,6 +100,15 @@ dsh plugin --profile <profile> add github:XBXyftx/dsh-session-messaging
92
100
  dsh plugin --profile <profile> add /absolute/path/to/dsh-session-messaging
93
101
  ```
94
102
 
103
+ ### 排障:从本地 link 切换到 npm 安装
104
+
105
+ 如果 profile 之前 link 了本地源码,又在**应用运行中**安装了 npm 包,运行中的 Loader 可能仍引用旧源码路径;此时删除源码目录的 `node_modules` 会出现 `@deepseek-ai/*` 的 `ERR_MODULE_NOT_FOUND`,即使 npm 包已经安装。先移除再重装 bundle,然后重启应用(客户端部分刷新页面即可):
106
+
107
+ ```sh
108
+ dsh plugin --profile <profile> remove dsh-session-messaging
109
+ dsh plugin --profile <profile> add dsh-session-messaging@latest
110
+ ```
111
+
95
112
  ### 桌面应用
96
113
 
97
114
  桌面应用使用自己的 profile(例如 `desktop`),用其内置 CLI 或 agent 的 `plugin_manager` 工具安装到该 profile。dsh `0.2.1-rc.2` 会通过 HMR 重载配置,通常无需重启应用即可激活。
@@ -135,13 +152,14 @@ dsh plugin --profile <profile> add /absolute/path/to/dsh-session-messaging
135
152
  requireTargetOptIn: false # 默认对 peer 开放
136
153
  sameDirectoryOnly: false
137
154
  allowSelfSend: false
155
+ allowColdDelivery: true # 目标不 live 时先恢复持久会话
138
156
  ```
139
157
 
140
158
  ## 安全模型与限制
141
159
 
142
160
  - **peer 内容是可信边界外的输入**:它以 user 消息进入目标会话,信封会标注来源并警告模型,但 dsh 没有针对 peer 消息的内容审批点;工具审批、`agent/pre-step` 与 sandbox 策略仍是唯一的硬边界。
143
161
  - **只允许顶层会话,收发两个方向都对称**:subagent 会话既不会出现在 peers 里,也不允许发送(`caller-not-top-level`);顶层会话也不能把 subagent 作为目标(`target-not-top-level`)。subagent 继续使用既有的父子 `send_message` 通道。消息信封的 `from` 字段才是可信身份;正文里的任何身份自称都只是数据。
144
- - **仅同进程**:发现依赖 `ctx.agents`,非 live 会话本版无法接收;冷会话投递与跨进程/跨机器通信未实现。
162
+ - **冷会话是“恢复后投递”,不是排队**:目标不 live 且 `allowColdDelivery` 开启时,插件调用部署的 `sessionController.resolveAgent()` 恢复该会话,可能启动模型 turn(产生 token 成本)。没有该服务的 profile 仍返回 `target-not-live`;归档或 subagent 拥有的会话会被控制器/会话门禁拒绝。跨进程/跨机器通信仍未实现。
145
163
  - **写范围只是建议,不是锁**:dsh 只有乐观版本守卫(`FS_STALE_VERSION`),没有文件锁;Bash、格式化器、代码生成器会绕过写范围提示。
146
164
  - **状态在内存**:暂停接收与写范围声明随插件生命周期存在,重载/重启后重置。
147
165
  - **默认允许跨目录**:不同工作目录的会话也可以互发消息。设置 `sameDirectoryOnly: true` 可恢复路径边界;无论是否限制,peer 内容都不可信。
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Browser half: a sidebar entry and its panel for session-messaging settings,
3
+ * update check, and author links. Built as a ModuleLoader factory (see the
4
+ * dsh plugin UI template); the Host half carries the service and tools.
5
+ */
6
+ window.__ModuleLoader__.load({
7
+ id: 'dsh-session-messaging',
8
+ factory(require) {
9
+ const React = require('react')
10
+ const h = React.createElement
11
+ const NS = 'session-messaging'
12
+ const PANEL_ID = 'session-messaging'
13
+ const PACKAGE_NAME = 'dsh-session-messaging'
14
+ const FALLBACK_VERSION = '0.3.0'
15
+ const NPM_URL = 'https://www.npmjs.com/package/dsh-session-messaging'
16
+ const GITHUB_URL = 'https://github.com/XBXyftx/dsh-session-messaging'
17
+ const BLOG_URL = 'https://xbxyftx.top'
18
+
19
+ const zh = {
20
+ 'panel.label': '跨会话消息',
21
+ 'page.title': '跨会话消息',
22
+ 'page.subtitle': '让同一 Host 内的多个用户会话互相发消息、协调任务与写入范围。',
23
+ 'setting.title': '允许跨工作目录消息',
24
+ 'setting.desc': '开启后,不同工作目录的顶层会话也能互相发现和发送消息。',
25
+ 'setting.unavailable': '设置服务当前不可用,无法在线修改。',
26
+ 'setting.loading': '正在读取配置…',
27
+ 'setting.saveFailed': '保存失败,请重试。',
28
+ 'update.title': '版本更新',
29
+ 'update.checking': '正在检查更新…',
30
+ 'update.upToDate': '已是最新版本',
31
+ 'update.available': '发现新版本',
32
+ 'update.failed': '更新检查失败',
33
+ 'update.button': '检查更新',
34
+ 'author.title': '作者',
35
+ 'author.desc': '由 XBXyftx 开发,MIT 开源。',
36
+ 'links.npm': 'npm',
37
+ 'links.github': 'GitHub',
38
+ 'links.blog': '作者博客',
39
+ }
40
+ const en = {
41
+ 'panel.label': 'Session Messaging',
42
+ 'page.title': 'Session Messaging',
43
+ 'page.subtitle': 'Let user-launched sessions in the same Host message each other, coordinate tasks, and declare write scopes.',
44
+ 'setting.title': 'Allow cross-directory messages',
45
+ 'setting.desc': 'When on, top-level sessions in different working directories can discover and message each other.',
46
+ 'setting.unavailable': 'The settings service is unavailable; this value cannot be edited here.',
47
+ 'setting.loading': 'Reading configuration…',
48
+ 'setting.saveFailed': 'Saving failed. Try again.',
49
+ 'update.title': 'Updates',
50
+ 'update.checking': 'Checking for updates…',
51
+ 'update.upToDate': 'Up to date',
52
+ 'update.available': 'Update available',
53
+ 'update.failed': 'Update check failed',
54
+ 'update.button': 'Check for updates',
55
+ 'author.title': 'Author',
56
+ 'author.desc': 'Built by XBXyftx. MIT licensed.',
57
+ 'links.npm': 'npm',
58
+ 'links.github': 'GitHub',
59
+ 'links.blog': 'Blog',
60
+ }
61
+
62
+ const color = {
63
+ text: 'var(--dsw-alias-label-primary)',
64
+ secondary: 'var(--dsw-alias-label-secondary)',
65
+ border: 'var(--dsw-alias-border-l1)',
66
+ surface: 'var(--dsw-alias-bg-layer-1)',
67
+ accent: 'var(--dsw-alias-brand-primary)',
68
+ error: 'var(--dsw-alias-state-error-primary)',
69
+ }
70
+ const pageStyle = {
71
+ display: 'flex', flexDirection: 'column', gap: 16, width: '100%',
72
+ maxWidth: 760, margin: '0 auto', padding: '28px 32px', boxSizing: 'border-box',
73
+ color: color.text, overflowY: 'auto',
74
+ }
75
+ const cardStyle = {
76
+ display: 'flex', flexDirection: 'column', gap: 14,
77
+ background: color.surface, border: `1px solid ${color.border}`,
78
+ borderRadius: 12, padding: 18,
79
+ }
80
+ const rowStyle = { display: 'flex', alignItems: 'center', gap: 16, justifyContent: 'space-between' }
81
+ const titleStyle = { fontSize: 15, fontWeight: 600, margin: 0, color: color.text }
82
+ const descStyle = { fontSize: 13, lineHeight: 1.5, margin: '6px 0 0', color: color.secondary }
83
+ const linkStyle = { color: color.accent, textDecoration: 'none', fontSize: 13 }
84
+ const buttonStyle = {
85
+ appearance: 'none', border: `1px solid ${color.border}`, borderRadius: 8,
86
+ background: 'transparent', color: color.text, padding: '6px 12px',
87
+ fontSize: 13, cursor: 'pointer',
88
+ }
89
+
90
+ function Icon({ size = 16 }) {
91
+ return h('svg', {
92
+ viewBox: '0 0 24 24', width: size, height: size, fill: 'none',
93
+ stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round',
94
+ strokeLinejoin: 'round', 'aria-hidden': true, style: { display: 'block' },
95
+ },
96
+ h('path', { d: 'M4 6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H8l-4 3z' }),
97
+ h('path', { d: 'M9 13h9a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2h-2l-3 2v-2h-4a2 2 0 0 1-2-2z' }))
98
+ }
99
+
100
+ function compareVersions(a, b) {
101
+ const pa = String(a).split('-')[0].split('.').map(Number)
102
+ const pb = String(b).split('-')[0].split('.').map(Number)
103
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
104
+ const x = pa[i] ?? 0
105
+ const y = pb[i] ?? 0
106
+ if (x !== y) return x - y
107
+ }
108
+ return 0
109
+ }
110
+
111
+ function Panel({ t, form, loadCurrentVersion }) {
112
+ const snapshot = React.useSyncExternalStore(
113
+ React.useCallback(listener => form.subscribe(listener), [form]),
114
+ React.useCallback(() => form.getSnapshot(), [form]),
115
+ React.useCallback(() => form.getSnapshot(), [form]),
116
+ )
117
+ const [saving, setSaving] = React.useState(false)
118
+ const [saveFailed, setSaveFailed] = React.useState(false)
119
+ const [currentVersion, setCurrentVersion] = React.useState(FALLBACK_VERSION)
120
+ const [update, setUpdate] = React.useState({ status: 'checking', latest: undefined })
121
+ const ready = snapshot.status === 'ready'
122
+ const enabled = (snapshot.value && snapshot.value.sameDirectoryOnly) === true
123
+
124
+ const check = React.useCallback(async () => {
125
+ setUpdate(previous => ({ ...previous, status: 'checking' }))
126
+ try {
127
+ const response = await fetch('https://registry.npmjs.org/dsh-session-messaging/latest')
128
+ if (!response.ok) throw new Error(String(response.status))
129
+ const data = await response.json()
130
+ setUpdate({ status: 'done', latest: typeof data.version === 'string' ? data.version : undefined })
131
+ } catch (_error) {
132
+ setUpdate({ status: 'failed', latest: undefined })
133
+ }
134
+ }, [])
135
+
136
+ React.useEffect(() => {
137
+ let cancelled = false
138
+ void loadCurrentVersion().then(version => { if (!cancelled) setCurrentVersion(version) })
139
+ void check()
140
+ return () => { cancelled = true }
141
+ }, [check, loadCurrentVersion])
142
+
143
+ const toggle = async () => {
144
+ if (!ready || saving) return
145
+ setSaving(true)
146
+ setSaveFailed(false)
147
+ try {
148
+ const accepted = await form.set('sameDirectoryOnly', !enabled)
149
+ if (!accepted) setSaveFailed(true)
150
+ } catch (_error) {
151
+ setSaveFailed(true)
152
+ } finally {
153
+ setSaving(false)
154
+ }
155
+ }
156
+
157
+ const newer = update.status === 'done' && update.latest !== undefined
158
+ && compareVersions(update.latest, currentVersion) > 0
159
+
160
+ const settingHint = snapshot.status === 'loading'
161
+ ? t('setting.loading')
162
+ : snapshot.status === 'unavailable'
163
+ ? t('setting.unavailable')
164
+ : saveFailed ? t('setting.saveFailed') : t('setting.desc')
165
+
166
+ return h('div', { style: pageStyle },
167
+ h('div', null,
168
+ h('h1', { style: { fontSize: 22, fontWeight: 650, margin: 0 } }, t('page.title')),
169
+ h('p', { style: { ...descStyle, fontSize: 14, marginTop: 6 } }, t('page.subtitle')),
170
+ ),
171
+ h('section', { style: cardStyle },
172
+ h('div', { style: rowStyle },
173
+ h('div', null,
174
+ h('h2', { style: titleStyle }, t('setting.title')),
175
+ h('p', { style: { ...descStyle, color: saveFailed ? color.error : color.secondary } }, settingHint),
176
+ ),
177
+ h('input', {
178
+ type: 'checkbox', checked: enabled, disabled: !ready || saving,
179
+ onChange: () => { void toggle() }, 'aria-label': t('setting.title'),
180
+ style: { width: 18, height: 18, accentColor: color.accent, cursor: ready ? 'pointer' : 'default' },
181
+ }),
182
+ ),
183
+ ),
184
+ h('section', { style: cardStyle },
185
+ h('div', { style: rowStyle },
186
+ h('div', null,
187
+ h('h2', { style: titleStyle }, t('update.title')),
188
+ h('p', { style: descStyle },
189
+ update.status === 'checking' ? t('update.checking')
190
+ : update.status === 'failed' ? t('update.failed')
191
+ : newer ? `${t('update.available')} · v${update.latest}`
192
+ : `${t('update.upToDate')} · v${currentVersion}`),
193
+ ),
194
+ h('button', { type: 'button', style: buttonStyle, onClick: () => { void check() }, disabled: update.status === 'checking' },
195
+ t('update.button')),
196
+ ),
197
+ newer ? h('a', { href: NPM_URL, target: '_blank', rel: 'noreferrer', style: linkStyle }, t('links.npm') + ' · v' + update.latest) : null,
198
+ ),
199
+ h('section', { style: cardStyle },
200
+ h('div', { style: rowStyle },
201
+ h('div', null,
202
+ h('h2', { style: titleStyle }, t('author.title')),
203
+ h('p', { style: descStyle }, t('author.desc')),
204
+ ),
205
+ h('div', { style: { display: 'flex', gap: 14 } },
206
+ h('a', { href: NPM_URL, target: '_blank', rel: 'noreferrer', style: linkStyle }, t('links.npm')),
207
+ h('a', { href: GITHUB_URL, target: '_blank', rel: 'noreferrer', style: linkStyle }, t('links.github')),
208
+ h('a', { href: BLOG_URL, target: '_blank', rel: 'noreferrer', style: linkStyle }, t('links.blog')),
209
+ ),
210
+ ),
211
+ ),
212
+ )
213
+ }
214
+
215
+ return {
216
+ inject: ['slots', 'locale', 'configForms', 'remote', 'remote.pluginManager'],
217
+ apply(ctx) {
218
+ const t = ctx.locale.bind(NS)
219
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-session-messaging: locale')
220
+ const form = ctx.configForms.get(PANEL_ID)
221
+
222
+ const currentVersion = () => ctx.remote.pluginManager.listBundles()
223
+ .then(rows => {
224
+ const rowsArray = Array.isArray(rows) ? rows : []
225
+ const own = rowsArray.find(row => row && row.name === PACKAGE_NAME)
226
+ return own && typeof own.version === 'string' ? own.version : FALLBACK_VERSION
227
+ })
228
+ .catch(() => FALLBACK_VERSION)
229
+
230
+ ctx.slots.inject('main', () => ctx.slots.register({
231
+ name: 'main', key: PANEL_ID, locale: NS,
232
+ inject: () => ({ t, form, loadCurrentVersion: currentVersion }),
233
+ }, Panel))
234
+
235
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
236
+ name: 'sidebar.panellist', id: PANEL_ID, order: 5,
237
+ label: () => t('panel.label'), locale: NS,
238
+ }, Icon))
239
+ },
240
+ }
241
+ },
242
+ })
package/lib/config.d.ts CHANGED
@@ -13,10 +13,36 @@ export interface SessionMessagingConfig {
13
13
  readonly maxMessagesPerMinute: number;
14
14
  /** Require the target session to opt in before it can receive peer messages. Off by default: sessions are mutually visible and can message each other unless they pause reception. */
15
15
  readonly requireTargetOptIn: boolean;
16
- /** Restrict peers to the sender's working directory. Off by default: cross-directory peers are allowed. */
16
+ /**
17
+ * Restrict peers to the sender's working directory. Off by default:
18
+ * cross-directory peers are allowed. Declared volatile so the sidebar panel
19
+ * can edit it through the dsh settings form.
20
+ */
17
21
  readonly sameDirectoryOnly: boolean;
18
22
  /** Allow a session to relay to itself (diagnostics only; off by default). */
19
23
  readonly allowSelfSend: boolean;
24
+ /** Resume a persisted, non-live session through sessionController before delivery. */
25
+ readonly allowColdDelivery: boolean;
20
26
  }
21
27
  /** Schemastery schema for the plugin row's `config` block. */
22
- export declare const Config: Schema<SessionMessagingConfig>;
28
+ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
29
+ maxMessageChars: Schema<number, number, "defined">;
30
+ maxMessagesPerMinute: Schema<number, number, "defined">;
31
+ requireTargetOptIn: Schema<boolean, boolean, "defined">;
32
+ sameDirectoryOnly: Schema<boolean, boolean, "volatile-defined">;
33
+ allowSelfSend: Schema<boolean, boolean, "defined">;
34
+ allowColdDelivery: Schema<boolean, boolean, "defined">;
35
+ }>>, Schemastery.ObjectT<NoInfer<{
36
+ maxMessageChars: Schema<number, number, "defined">;
37
+ maxMessagesPerMinute: Schema<number, number, "defined">;
38
+ requireTargetOptIn: Schema<boolean, boolean, "defined">;
39
+ sameDirectoryOnly: Schema<boolean, boolean, "volatile-defined">;
40
+ allowSelfSend: Schema<boolean, boolean, "defined">;
41
+ allowColdDelivery: Schema<boolean, boolean, "defined">;
42
+ }>>, "plain">;
43
+ /** Plugin runtime config: volatile fields arrive as stable references. */
44
+ export interface ResolvedSessionMessagingConfig extends Omit<SessionMessagingConfig, 'sameDirectoryOnly'> {
45
+ readonly sameDirectoryOnly: {
46
+ get(): boolean;
47
+ };
48
+ }
package/lib/config.js CHANGED
@@ -11,6 +11,7 @@ export const Config = Schema.object({
11
11
  maxMessageChars: Schema.number().min(1).default(DEFAULT_MAX_MESSAGE_CHARS),
12
12
  maxMessagesPerMinute: Schema.number().min(1).default(DEFAULT_MAX_MESSAGES_PER_MINUTE),
13
13
  requireTargetOptIn: Schema.boolean().default(false),
14
- sameDirectoryOnly: Schema.boolean().default(false),
14
+ sameDirectoryOnly: Schema.boolean().default(false).volatile(),
15
15
  allowSelfSend: Schema.boolean().default(false),
16
+ allowColdDelivery: Schema.boolean().default(true),
16
17
  });
package/lib/protocol.d.ts CHANGED
@@ -17,7 +17,7 @@ export declare const MAX_WRITE_SCOPES = 32;
17
17
  /** Maximum length of one declared write scope. */
18
18
  export declare const MAX_WRITE_SCOPE_CHARS = 200;
19
19
  /** Stable refusal reasons surfaced to callers and tests. */
20
- export type MessagingErrorCode = 'invalid-request' | 'caller-not-top-level' | 'self-send' | 'target-not-live' | 'target-not-top-level' | 'target-not-opted-in' | 'target-paused' | 'cross-directory' | 'rate-limited' | 'delivery-rejected';
20
+ export type MessagingErrorCode = 'invalid-request' | 'caller-not-top-level' | 'self-send' | 'target-not-live' | 'target-not-top-level' | 'target-not-opted-in' | 'target-paused' | 'cold-delivery-failed' | 'cross-directory' | 'rate-limited' | 'delivery-rejected';
21
21
  /** Typed refusal that a tool call surfaces as an error result. */
22
22
  export declare class MessagingError extends Error {
23
23
  readonly code: MessagingErrorCode;
@@ -43,6 +43,8 @@ export interface SendReceipt {
43
43
  readonly targetStatus: 'idle' | 'running';
44
44
  /** Advisory overlaps with peers' declared write scopes; empty means none detected. */
45
45
  readonly conflicts: readonly ScopeConflict[];
46
+ /** Whether delivery used a live Agent or resumed a persisted session first. */
47
+ readonly delivery: 'live' | 'resumed';
46
48
  }
47
49
  /** One same-workspace peer as the model sees it. */
48
50
  export interface PeerView {
package/lib/service.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import { Service, type Context } from '@deepseek-ai/cordis';
10
10
  import type { Agent } from '@deepseek-ai/dsh-agent';
11
11
  import { Config } from './config.ts';
12
- import type { SessionMessagingConfig } from './config.ts';
12
+ import type { ResolvedSessionMessagingConfig } from './config.ts';
13
13
  import type { PeerView, SendReceipt, SendRequest } from './protocol.ts';
14
14
  /** One session's receive consent and advisory write scopes. */
15
15
  interface ConsentEntry {
@@ -31,7 +31,21 @@ declare module '@deepseek-ai/cordis' {
31
31
  export declare class SessionMessagingService extends Service {
32
32
  static inject: string[];
33
33
  /** Loader-read configuration schema; defaults apply when the row omits `config`. */
34
- static Config: import("@deepseek-ai/schemastery").default<SessionMessagingConfig>;
34
+ static Config: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<NoInfer<{
35
+ maxMessageChars: import("@deepseek-ai/schemastery").default<number, number, "defined">;
36
+ maxMessagesPerMinute: import("@deepseek-ai/schemastery").default<number, number, "defined">;
37
+ requireTargetOptIn: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
38
+ sameDirectoryOnly: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
39
+ allowSelfSend: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
40
+ allowColdDelivery: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
41
+ }>>, Schemastery.ObjectT<NoInfer<{
42
+ maxMessageChars: import("@deepseek-ai/schemastery").default<number, number, "defined">;
43
+ maxMessagesPerMinute: import("@deepseek-ai/schemastery").default<number, number, "defined">;
44
+ requireTargetOptIn: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
45
+ sameDirectoryOnly: import("@deepseek-ai/schemastery").default<boolean, boolean, "volatile-defined">;
46
+ allowSelfSend: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
47
+ allowColdDelivery: import("@deepseek-ai/schemastery").default<boolean, boolean, "defined">;
48
+ }>>, "plain">;
35
49
  private readonly config;
36
50
  private readonly limiter;
37
51
  private readonly consent;
@@ -39,7 +53,7 @@ export declare class SessionMessagingService extends Service {
39
53
  * @param ctx - Cordis context with `agents` and `sessions` ready.
40
54
  * @param config - validated plugin configuration.
41
55
  */
42
- constructor(ctx: Context, config: SessionMessagingConfig);
56
+ constructor(ctx: Context, config: ResolvedSessionMessagingConfig);
43
57
  /**
44
58
  * List live user-launched peers the caller may address.
45
59
  * @param caller - the agent whose tool call is inspecting peers.
@@ -68,7 +82,24 @@ export declare class SessionMessagingService extends Service {
68
82
  */
69
83
  private assertTopLevelCaller;
70
84
  /**
71
- * Relay one message to a live peer session.
85
+ * Enforce per-target consent and the sender→target rate limit once.
86
+ * @param caller - sending agent.
87
+ * @param targetId - target session identity.
88
+ */
89
+ private checkConsentAndRate;
90
+ /**
91
+ * Resume a persisted, non-live session through the deployment's session
92
+ * controller. Web and Desktop mount one; profiles without it keep the
93
+ * `target-not-live` refusal.
94
+ * @param targetId - persisted session identity to resume.
95
+ * @returns the resumed live Agent.
96
+ * @throws {MessagingError} with `target-not-live` without a controller, or
97
+ * `cold-delivery-failed` when resume refuses.
98
+ */
99
+ private resumeColdAgent;
100
+ /**
101
+ * Relay one message to a live peer session, resuming a persisted session
102
+ * first when `allowColdDelivery` is enabled.
72
103
  *
73
104
  * Success means the target agent accepted the message into its inbox and the
74
105
  * session flush acknowledged persistence. It does not mean the target model
package/lib/service.js CHANGED
@@ -47,7 +47,7 @@ export class SessionMessagingService extends Service {
47
47
  continue;
48
48
  if (!isTopLevelSessionHeader(candidate.session.header))
49
49
  continue;
50
- if (this.config.sameDirectoryOnly && !sameDirectory(callerDirectory, candidate.session.header.cwd)) {
50
+ if (this.config.sameDirectoryOnly.get() && !sameDirectory(callerDirectory, candidate.session.header.cwd)) {
51
51
  continue;
52
52
  }
53
53
  const consent = this.consent.get(candidate.id);
@@ -95,7 +95,53 @@ export class SessionMessagingService extends Service {
95
95
  throw new MessagingError('caller-not-top-level', `session ${caller.id} is not a user-launched top-level session; subagent sessions use the parent/child channel`);
96
96
  }
97
97
  /**
98
- * Relay one message to a live peer session.
98
+ * Enforce per-target consent and the sender→target rate limit once.
99
+ * @param caller - sending agent.
100
+ * @param targetId - target session identity.
101
+ */
102
+ checkConsentAndRate(caller, targetId) {
103
+ const consent = this.consent.get(targetId);
104
+ if (this.config.requireTargetOptIn && consent?.enabled !== true) {
105
+ throw new MessagingError('target-not-opted-in', `session ${targetId} has not opted in to peer messages`);
106
+ }
107
+ if (consent?.enabled === false) {
108
+ throw new MessagingError('target-paused', `session ${targetId} has paused peer messages`);
109
+ }
110
+ const pairKey = `${caller.id}->${targetId}`;
111
+ if (!this.limiter.allow(pairKey, Date.now())) {
112
+ throw new MessagingError('rate-limited', `more than ${this.config.maxMessagesPerMinute} messages per minute were sent to this session`);
113
+ }
114
+ }
115
+ /**
116
+ * Resume a persisted, non-live session through the deployment's session
117
+ * controller. Web and Desktop mount one; profiles without it keep the
118
+ * `target-not-live` refusal.
119
+ * @param targetId - persisted session identity to resume.
120
+ * @returns the resumed live Agent.
121
+ * @throws {MessagingError} with `target-not-live` without a controller, or
122
+ * `cold-delivery-failed` when resume refuses.
123
+ */
124
+ async resumeColdAgent(targetId) {
125
+ const controller = this.ctx.get('sessionController');
126
+ if (controller === undefined) {
127
+ throw new MessagingError('target-not-live', `session ${targetId} is not live and this profile has no session controller for cold resume`);
128
+ }
129
+ let resolved;
130
+ try {
131
+ resolved = await controller.resolveAgent(targetId);
132
+ }
133
+ catch (error) {
134
+ throw new MessagingError('cold-delivery-failed', `cold resume failed for ${targetId}: ${error instanceof Error ? error.message : String(error)}`);
135
+ }
136
+ if ('error' in resolved) {
137
+ const detail = resolved.error.message === undefined ? '' : ` (${resolved.error.message})`;
138
+ throw new MessagingError('cold-delivery-failed', `cold resume failed for ${targetId}: ${resolved.error.code ?? 'error'}${detail}`);
139
+ }
140
+ return resolved.agent;
141
+ }
142
+ /**
143
+ * Relay one message to a live peer session, resuming a persisted session
144
+ * first when `allowColdDelivery` is enabled.
99
145
  *
100
146
  * Success means the target agent accepted the message into its inbox and the
101
147
  * session flush acknowledged persistence. It does not mean the target model
@@ -130,27 +176,27 @@ export class SessionMessagingService extends Service {
130
176
  .filter(([sessionId]) => sessionId !== caller.id)
131
177
  .map(([sessionId, entry]) => ({ sessionId, scopes: entry.writeScopes }))
132
178
  .filter(peer => peer.scopes.length > 0));
133
- const target = this.ctx.agents.get(targetId);
179
+ let target = this.ctx.agents.get(targetId);
180
+ let delivery = 'live';
181
+ let policyChecked = false;
134
182
  if (target === undefined) {
135
- throw new MessagingError('target-not-live', `session ${targetId} is not live in this Harness process; cold-session delivery is not implemented yet`);
183
+ if (!this.config.allowColdDelivery) {
184
+ throw new MessagingError('target-not-live', `session ${targetId} is not live and cold-session delivery is disabled`);
185
+ }
186
+ // Check consent and rate limits before paying for a resume/turn.
187
+ this.checkConsentAndRate(caller, targetId);
188
+ policyChecked = true;
189
+ target = await this.resumeColdAgent(targetId);
190
+ delivery = 'resumed';
136
191
  }
137
192
  if (!isTopLevelSessionHeader(target.session.header)) {
138
193
  throw new MessagingError('target-not-top-level', `session ${targetId} is not a user-launched top-level session`);
139
194
  }
140
- if (this.config.sameDirectoryOnly && !sameDirectory(caller.session.header.cwd, target.session.header.cwd)) {
195
+ if (this.config.sameDirectoryOnly.get() && !sameDirectory(caller.session.header.cwd, target.session.header.cwd)) {
141
196
  throw new MessagingError('cross-directory', 'the target session works in a different directory');
142
197
  }
143
- const consent = this.consent.get(targetId);
144
- if (this.config.requireTargetOptIn && consent?.enabled !== true) {
145
- throw new MessagingError('target-not-opted-in', `session ${targetId} has not opted in to peer messages`);
146
- }
147
- if (consent?.enabled === false) {
148
- throw new MessagingError('target-paused', `session ${targetId} has paused peer messages`);
149
- }
150
- const pairKey = `${caller.id}->${targetId}`;
151
- if (!this.limiter.allow(pairKey, Date.now())) {
152
- throw new MessagingError('rate-limited', `more than ${this.config.maxMessagesPerMinute} messages per minute were sent to this session`);
153
- }
198
+ if (!policyChecked)
199
+ this.checkConsentAndRate(caller, targetId);
154
200
  const messageId = `session-message-${randomUUID()}`;
155
201
  const text = buildRelayText({
156
202
  senderSessionId: caller.id,
@@ -182,6 +228,7 @@ export class SessionMessagingService extends Service {
182
228
  targetSessionId: targetId,
183
229
  targetStatus: target.status,
184
230
  conflicts,
231
+ delivery,
185
232
  };
186
233
  }
187
234
  }
package/lib/tools.js CHANGED
@@ -89,6 +89,7 @@ export function apply(ctx) {
89
89
  messageId: { type: 'string', required: true },
90
90
  targetSessionId: { type: 'string', required: true },
91
91
  targetStatus: { type: 'string', required: true, enum: ['idle', 'running'] },
92
+ delivery: { type: 'string', required: true, enum: ['live', 'resumed'] },
92
93
  conflicts: {
93
94
  type: 'array',
94
95
  required: true,
@@ -108,9 +109,10 @@ export function apply(ctx) {
108
109
  const warning = value.conflicts.length === 0
109
110
  ? ''
110
111
  : ` Conflict warning: ${value.conflicts.map(conflict => `${conflict.sessionId} also declared ${conflict.scopes.join(', ')}`).join('; ')}. Coordinate before writing those paths.`;
112
+ const resumed = value.delivery === 'resumed' ? ' The target was a persisted session and was resumed for delivery.' : '';
111
113
  return [{
112
114
  type: 'text',
113
- text: `Message ${value.messageId} accepted by session ${value.targetSessionId} (${value.targetStatus}). Acceptance means the message is in that session's inbox; it does not confirm the peer processed it.${warning}`,
115
+ text: `Message ${value.messageId} accepted by session ${value.targetSessionId} (${value.targetStatus}). Acceptance means the message is in that session's inbox; it does not confirm the peer processed it.${resumed}${warning}`,
114
116
  }];
115
117
  },
116
118
  presentationMeta: (_args, value) => ({ targetSessionId: value.targetSessionId, messageId: value.messageId }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-messaging",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Peer messaging between user-launched sessions in one workspace: discover peers, relay coordination messages, pause reception, and get advisory write-scope conflict warnings.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,6 +15,7 @@
15
15
  "types": "./lib/tools.d.ts",
16
16
  "default": "./lib/tools.js"
17
17
  },
18
+ "./client": "./client/client.js",
18
19
  "./locale/*.json": "./locale/*.json",
19
20
  "./tools/locale/*.json": "./tools/locale/*.json",
20
21
  "./tools/icon": "./assets/tools-icon.svg",
@@ -23,6 +24,7 @@
23
24
  },
24
25
  "files": [
25
26
  "lib",
27
+ "client",
26
28
  "locale",
27
29
  "tools/locale",
28
30
  "assets",
@@ -60,7 +62,8 @@
60
62
  "@deepseek-ai/dsh-llm": "^0.2.1-rc.2",
61
63
  "@deepseek-ai/dsh-session": "^0.2.1-rc.2",
62
64
  "@deepseek-ai/dsh-tools": "^0.2.1-rc.2",
63
- "@deepseek-ai/schemastery": "^3.18.5"
65
+ "@deepseek-ai/schemastery": "^3.18.5",
66
+ "@deepseek-ai/dsh-api-session-controller": "^0.2.1-rc.2"
64
67
  },
65
68
  "devDependencies": {
66
69
  "@deepseek-ai/cordis": "^4.0.5",
@@ -71,18 +74,27 @@
71
74
  "@deepseek-ai/dsh-tools": "^0.2.1-rc.2",
72
75
  "@deepseek-ai/schemastery": "^3.18.5",
73
76
  "@types/node": "^24.0.0",
74
- "typescript": "^6.0.3"
77
+ "typescript": "^6.0.3",
78
+ "@deepseek-ai/dsh-api-session-controller": "^0.2.1-rc.2"
75
79
  },
76
80
  "scripts": {
77
81
  "build": "tsc -p tsconfig.build.json",
78
82
  "typecheck": "tsc -p tsconfig.json",
79
83
  "test": "node --experimental-strip-types --test tests/protocol.test.ts",
80
- "test:integration": "node --experimental-strip-types tests/integration-smoke.ts && node --experimental-strip-types tests/directory-policy-smoke.ts",
84
+ "test:integration": "node --experimental-strip-types tests/integration-smoke.ts && node --experimental-strip-types tests/directory-policy-smoke.ts && node --experimental-strip-types tests/cold-delivery-smoke.ts",
81
85
  "test:model-e2e": "node --experimental-strip-types tests/model-e2e-smoke.ts"
82
86
  },
83
87
  "dsh": {
84
88
  "bundle": {
85
89
  "patch": "./cordis.patch.yml"
90
+ },
91
+ "client": {
92
+ "platform": "web",
93
+ "immediately": true,
94
+ "inject": [
95
+ "@deepseek-ai/dsh-client-ui-settings",
96
+ "@deepseek-ai/dsh-client-ui-layout"
97
+ ]
86
98
  }
87
99
  },
88
100
  "icon": "./assets/icon.svg",