dsh-multi-chat 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 daetz-coder
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # 🧱 DSH 多窗口墙 · Multi-Window Wall
2
+
3
+ > **一个浏览器,并排盯住你所有的 AI 任务。** 让 DSH 从「一次一个对话」变成「一屏全景驾驶舱」,还能用手机躺着看进度。
4
+
5
+ 给 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) 官方 Web 界面装上一个**多窗口墙**:在一张网格里同时显示 N 个正在运行的 DSH 实例(每个实例独立跑一个任务),所有 Agent 的实时进度、对话、输出**一眼尽收**,不用在无数标签页/窗口之间切来切去。
6
+
7
+ ## ✨ 它能做什么
8
+
9
+ | 能力 | 说明 |
10
+ |------|------|
11
+ | 📺 **多窗口墙** | 侧边栏一键进入,右侧对话区原位变成窗口网格,一个端口一格,并排看全部任务 |
12
+ | 🔍 **自动发现** | 扫描端口区间自动发现正在运行的 DSH 实例,也可手动管理 |
13
+ | ➕ **一键新建窗口** | 墙内直接启动全新 DSH 实例,凑成你的多任务矩阵 |
14
+ | 📱 **手机访问** | 点「手机访问」自动起一个**带口令认证的局域网网关**,手机扫码/输入口令即可看进度 |
15
+ | 🛑 **窗口控制** | 单窗口放大、刷新、新标签页打开、关闭实例、列数切换(自动/1/2/3/4/6)|
16
+
17
+ > **多任务 = 多端口。** 启动 N 个 `dsh web --port <n>`,每个实例独立跑一个任务;在任意一个实例里打开多窗口墙,即可并排看到全部。
18
+
19
+ ## 🚀 30 秒上手
20
+
21
+ ```bash
22
+ # 1. 安装(npm / npx,免手工打包补丁)
23
+ npx dsh-multi-chat install
24
+
25
+ # 2. 启动几个实例
26
+ npx dsh-multi-chat start --ports 3080,3081,3082
27
+
28
+ # 3. 打开任意实例,点侧边栏底部「多窗口墙」→ 完成 🎉
29
+ ```
30
+
31
+ ## 为什么这样做
32
+
33
+ - **不改动任何官方逻辑**:插件只注册两个**增量列表槽位**(`conversation.view` 视图环条目、`sidebar.footer.action` 侧边栏快捷入口)和只读 JSON 探活路由(`/multi/api/ports`、`/multi/api/status`、`/multi/api/stop`)。不替换任何既有槽位、不改写任何行、不触碰会话/代理/工具等核心逻辑。
34
+ - **界面就是官方界面**:墙是官方视图环的一个视图,渲染在对话主面板内(不是弹层),主题、字号、图标、控件全部走官方 `--dsw-*` token 与官方 primitives(Button/Input/Menu/StateDot)。
35
+ - **递归防护**:墙永远不嵌入自身端口;被嵌入页面带 `?multi-wall=embed` 标记,不注册任何墙界面,杜绝「墙中墙」无限递归。
36
+ - **最小改动**:新增一个 client 插件包 + 一个 patch 行。
37
+
38
+ ## 目录结构
39
+
40
+ ```
41
+ plugin/dsh-client-ui-multi-wall/ # 官方规范 client 插件包(node half + browser half)
42
+ lib/ # 已构建产物(lib/index.js + lib/client.js + 类型)
43
+ src/ # 源码(与官方 monorepo packages/client/ui-multi-wall 一致)
44
+ patches/multi-wall.yml # 启用插件的 cordis.patch.yml insert 行
45
+ scripts/
46
+ install-plugin.ps1 # 打包 + 装进 profile + 追加 patch + 提示重启
47
+ start-multi.ps1 / stop-multi.ps1 # 启停多个 dsh web 实例(-Remote 可带认证网关)
48
+ gateway.mjs # 带令牌认证 / 可选 TLS 的反向代理网关(手机/远程访问)
49
+ gateway-hidden.vbs # 无窗口启动器:用隐藏窗口方式启动 gateway.mjs(不弹控制台)
50
+ gateway-start.ps1 / gateway-stop.ps1 # 一键静默启动/停止网关
51
+ bin/dsh-multi-chat.mjs # 跨平台 npx CLI(install/start/stop/gateway)
52
+ harness-src/ # 官方 deepseek-harness 源码(开发/构建用)
53
+ ```
54
+
55
+ ## 安装与启用(Windows)
56
+
57
+ ```powershell
58
+ # 1) 打包并装进 web profile,自动追加 patch 行
59
+ .\scripts\install-plugin.ps1
60
+
61
+ # 2) 重启 dsh web,打开任意实例
62
+ dsh web --port 3084
63
+ # 浏览器打开 http://127.0.0.1:3084 ,侧边栏底部出现「多窗口墙」按钮
64
+ ```
65
+
66
+ 或手动:
67
+
68
+ ```bash
69
+ cd plugin/dsh-client-ui-multi-wall && npm pack # 得到 tarball
70
+ dsh plugin --profile web add <tarball> # 装进 profile
71
+ # 把 patches/multi-wall.yml 的 insert 行加进 ~/.dsh/profiles/web/cordis.patch.yml
72
+ ```
73
+
74
+ ## 使用
75
+
76
+ 1. 先启动若干实例:`.\scripts\start-multi.ps1 -Ports "3080,3081,3082,3084"`(或手动 `dsh web --port <n>`)。
77
+ 2. 打开任意实例,点侧边栏底部的「多窗口墙」快捷入口(或点对话区头部的「多窗口墙」标签页)。
78
+ 3. 墙视图内:自动发现实例(自动排除自身端口)、列数切换(自动/1/2/3/4/6,默认横向铺满)、点标题放大、⟳ 单独刷新、↗ 新标签页打开、✕ 从视图移除、全部刷新、实时在线状态点。布局保存在 localStorage。
79
+ 4. 退出墙:点工具栏**右上角的「退出」按钮**,一键切回对话视图。
80
+
81
+ ## 手机 / 远程访问(认证网关)
82
+
83
+ 官方 `dsh web` 出于安全**刻意禁止 `--host 0.0.0.0`**(会向网络暴露远程代码执行)。因此跨设备访问的正确姿势是:实例保持仅本机回环,在实例前挂一个**带令牌认证的网关**,由网关对外监听 `0.0.0.0`。
84
+
85
+ ```bash
86
+ # 一个实例 + 一个认证网关(手机在同一内网时打开 http://<局域网IP>:8443 登录)
87
+ node scripts/gateway.mjs --target 127.0.0.1:3080 --listen 0.0.0.0:8443 --token <口令>
88
+
89
+ # 加密:提供证书即走 HTTPS(跨公网必须,否则用 VPN)
90
+ node scripts/gateway.mjs --target 127.0.0.1:3080 --listen 0.0.0.0:8443 --token <口令> --tls-cert cert.pem --tls-key key.pem
91
+ ```
92
+
93
+ 网关的安全模型:HMAC 签名的 HttpOnly/SameSite 会话 Cookie(默认 12h)、`Authorization: Bearer` 与 `?token=` 供脚本使用、按 IP 限流登录失败;所有代理请求把 Host/Origin 重写为回环目标,官方 `/api` 浏览器信任栅栏(DNS-rebinding 防线)因此判定为本地请求,无需重启加 `--trusted-host`;WebSocket 升级与 SSE 流原样透传。
94
+
95
+ `start-multi.ps1` 也能一键带网关启动:
96
+
97
+ ```powershell
98
+ .\scripts\start-multi.ps1 -Ports "3080,3081" -Remote -Token "my-secret"
99
+ # 或自签名加密:-TlsCert cert.pem -TlsKey key.pem
100
+ ```
101
+
102
+ ## 分发与安装
103
+
104
+ 仓库内置跨平台 CLI `dsh-multi-chat`(`bin/dsh-multi-chat.mjs`),下面三种渠道都可安装。CLI 的 `install` 会探测 `$DSH_HOME`(缺省 `~/.dsh`)并幂等地追加启用 patch(与 `install-plugin.ps1` 行为一致)。
105
+
106
+ ### 渠道一:npm / npx(推荐,最省事)
107
+
108
+ ```bash
109
+ # 发布到 npm 后,任意机器一句话安装
110
+ npx dsh-multi-chat install
111
+
112
+ # 或直接 npx 跑单条命令(无需安装)
113
+ npx dsh-multi-chat start --remote --token <口令> --ports 3080,3081
114
+ npx dsh-multi-chat gateway --target 127.0.0.1:3080 --token <口令>
115
+ ```
116
+
117
+ 维护者发布:`npm publish`(无作用域公开包 `dsh-multi-chat`)。
118
+
119
+ ### 渠道二:GitHub Release
120
+
121
+ 从 [Releases](https://github.com/daetz-coder/dsh-multi-chat/releases) 下载源码 zip/tarball,解压后进目录:
122
+
123
+ ```bash
124
+ node bin/dsh-multi-chat.mjs install # 打包 + dsh plugin add + 追加启用 patch
125
+ node bin/dsh-multi-chat.mjs start --ports 3080,3081
126
+ ```
127
+
128
+ > 打 tag 后,GitHub 会自动生成 source zip/tarball 资产;也可在 Release 附加 `npm pack` 产出的 `.tgz` 作为离线安装包。
129
+
130
+ ### 渠道三:git 直接安装
131
+
132
+ ```bash
133
+ git clone https://github.com/daetz-coder/dsh-multi-chat.git
134
+ cd dsh-multi-chat
135
+
136
+ node bin/dsh-multi-chat.mjs install # 装插件
137
+ node bin/dsh-multi-chat.mjs start --ports 3080,3081
138
+ node bin/dsh-multi-chat.mjs gateway --target 127.0.0.1:3080 --token <口令>
139
+ ```
140
+
141
+ ### 本仓库直接运行(开发)
142
+
143
+ ```bash
144
+ node bin/dsh-multi-chat.mjs install
145
+ node bin/dsh-multi-chat.mjs start --ports 3080,3081
146
+ node bin/dsh-multi-chat.mjs stop
147
+ node bin/dsh-multi-chat.mjs gateway --target 127.0.0.1:3080 --token <口令>
148
+ ```
149
+
150
+ ## 在官方 monorepo 中的位置
151
+
152
+ `packages/client/ui-multi-wall` 是遵循官方 client 插件规范的包(tsconfig host/client 分离、tsdown clientBundle、locales zh/en、invariant 伴随、HMR 安全测试),并已接入 `packages/bundle/web-app` 的 dsh.client roster 与 `tsconfig.client.json` 聚合。构建:
153
+
154
+ ```bash
155
+ cd harness-src
156
+ pnpm install
157
+ pnpm --filter @deepseek-ai/dsh-client-ui-multi-wall bundle # 产出 lib/client.js
158
+ npx vitest run packages/client/ui-multi-wall # 14 项测试
159
+ ```
160
+
161
+ ## License
162
+
163
+ MIT
@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-multi-chat — install / manage the multi-window wall plugin and its
4
+ * authenticated gateway, from anywhere `npx dsh-multi-chat` can run.
5
+ *
6
+ * Subcommands:
7
+ * install [--profile web] pack the plugin, `dsh plugin add` it into the
8
+ * profile, and append the enable patch row.
9
+ * start [options] start N loopback `dsh web` instances (cross-
10
+ * platform replacement for scripts/start-multi.ps1);
11
+ * with --remote, put an authenticated gateway in
12
+ * front of each so a phone/remote machine can log
13
+ * in with a token.
14
+ * stop stop everything `start` recorded (instances +
15
+ * gateways).
16
+ * gateway [options...] run the authenticated gateway itself (forwards
17
+ * to scripts/gateway.mjs).
18
+ *
19
+ * The PowerShell scripts remain the Windows-native path; this CLI is the
20
+ * npx/distribution path and works on Windows, macOS, and Linux.
21
+ */
22
+
23
+ import { spawn, spawnSync } from 'node:child_process'
24
+ import { createServer as createNetServer } from 'node:net'
25
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
26
+ import { homedir, networkInterfaces } from 'node:os'
27
+ import { join, dirname } from 'node:path'
28
+ import { fileURLToPath } from 'node:url'
29
+
30
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
31
+ const STATE_FILE = join(ROOT, '.wall-pids.json')
32
+ const PLUGIN_DIR = join(ROOT, 'plugin', 'dsh-client-ui-multi-wall')
33
+
34
+ // Node ≥22 warns about spawn(shell:true, args[]) (DEP0190). The args here are
35
+ // fixed strings we control (numeric ports, our own paths), so the warning is
36
+ // noise; drop only that message and keep other warnings visible.
37
+ process.removeAllListeners('warning')
38
+ process.on('warning', (warning) => {
39
+ if (warning.name === 'DeprecationWarning' && /shell option true/.test(warning.message)) return
40
+ process.stderr.write(`Warning: ${warning.message}\n`)
41
+ })
42
+
43
+ /* ------------------------------------------------------------------ */
44
+ /* Small helpers. */
45
+ /* ------------------------------------------------------------------ */
46
+
47
+ function shell(win32) { return win32 || false }
48
+ const isWin = process.platform === 'win32'
49
+
50
+ function run(file, args, { cwd = ROOT, okCodes = [0] } = {}) {
51
+ const result = spawnSync(file, args, { cwd, shell: isWin, encoding: 'utf8' })
52
+ if (!okCodes.includes(result.status ?? -1)) {
53
+ throw new Error(`command failed (${file} ${args.join(' ')}) exit ${result.status}\n${result.stdout ?? ''}${result.stderr ?? ''}`)
54
+ }
55
+ return result.stdout ?? ''
56
+ }
57
+
58
+ function dshHome() {
59
+ return process.env.DSH_HOME && process.env.DSH_HOME !== ''
60
+ ? process.env.DSH_HOME
61
+ : join(homedir(), '.dsh')
62
+ }
63
+
64
+ function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) }
65
+
66
+ async function probe(port, timeoutMs = 600) {
67
+ try {
68
+ const res = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(timeoutMs) })
69
+ const body = await res.text()
70
+ return res.status === 200 && body.includes('__DSH_BOOT__')
71
+ } catch {
72
+ return false
73
+ }
74
+ }
75
+
76
+ async function waitReady(port, timeoutMs = 20000) {
77
+ const deadline = Date.now() + timeoutMs
78
+ for (;;) {
79
+ if (await probe(port)) return true
80
+ if (Date.now() > deadline) return false
81
+ await sleep(400)
82
+ }
83
+ }
84
+
85
+ function spawnDetached(file, args, { shell = false } = {}) {
86
+ const child = spawn(file, args, {
87
+ detached: true,
88
+ stdio: 'ignore',
89
+ windowsHide: true,
90
+ shell,
91
+ })
92
+ child.unref()
93
+ return child.pid
94
+ }
95
+
96
+ function killPid(pid) {
97
+ if (isWin) {
98
+ spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'], { stdio: 'ignore' })
99
+ } else {
100
+ try { process.kill(pid, 'SIGTERM') } catch { /* already gone */ }
101
+ setTimeout(() => { try { process.kill(pid, 'SIGKILL') } catch { /* already gone */ } }, 500)
102
+ }
103
+ }
104
+
105
+ function writeState(state) {
106
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf8')
107
+ }
108
+
109
+ function readState() {
110
+ try {
111
+ return JSON.parse(readFileSync(STATE_FILE, 'utf8'))
112
+ } catch {
113
+ return null
114
+ }
115
+ }
116
+
117
+ /* ------------------------------------------------------------------ */
118
+ /* install */
119
+ /* ------------------------------------------------------------------ */
120
+
121
+ const INSTALL_HELP = `dsh-multi-chat install — install the multi-window wall plugin
122
+
123
+ Usage:
124
+ dsh-multi-chat install [--profile web]
125
+
126
+ Packs plugin/dsh-client-ui-multi-wall, installs the tarball into the profile
127
+ with \`dsh plugin --profile <p> add\`, and appends the enable patch row to
128
+ $DSH_HOME/profiles/<p>/cordis.patch.yml. Idempotent: the patch row is only
129
+ added once. Restart dsh web afterwards.
130
+ `
131
+
132
+ async function cmdInstall(args) {
133
+ let profile = 'web'
134
+ for (let i = 0; i < args.length; i++) {
135
+ if (args[i] === '--profile') profile = args[++i] ?? 'web'
136
+ else if (args[i] === '--help') { process.stdout.write(INSTALL_HELP); return }
137
+ else throw new Error(`unknown install option: ${args[i]}`)
138
+ }
139
+ if (!existsSync(PLUGIN_DIR)) {
140
+ throw new Error(`plugin directory not found: ${PLUGIN_DIR} — run from the dsh-multi-chat package root`)
141
+ }
142
+
143
+ // 1) pack the plugin tarball into a STABLE location under DSH home. The
144
+ // profile records a `file:` dependency on the tarball, so a temp directory
145
+ // (deleted after install) would break every later `dsh plugin add` — the
146
+ // tarball must outlive the CLI run. (npm pack is offline-safe.)
147
+ const stableDir = join(dshHome(), 'profiles', profile, 'plugins')
148
+ mkdirSync(stableDir, { recursive: true })
149
+ const packOut = run('npm', ['pack', '--pack-destination', stableDir], { cwd: PLUGIN_DIR })
150
+ const tarballName = packOut.trim().split(/\r?\n/).pop()
151
+ const tarball = join(stableDir, tarballName)
152
+ if (!existsSync(tarball)) throw new Error('npm pack produced no tarball')
153
+
154
+ // 2) install into the profile.
155
+ process.stdout.write(`installing ${tarballName} into profile '${profile}'…\n`)
156
+ run('dsh', ['plugin', '--profile', profile, 'add', tarball])
157
+
158
+ // 3) append the enable patch row (idempotent).
159
+ const patchPath = join(dshHome(), 'profiles', profile, 'cordis.patch.yml')
160
+ const insert = [
161
+ '',
162
+ '# Multi-window wall (dsh-multi-chat): enable the official client',
163
+ '# plugin that renders the wall inside the DSH web GUI.',
164
+ '- insert:',
165
+ ' - id: ui-multi-wall',
166
+ " name: '@deepseek-ai/dsh-client-ui-multi-wall'",
167
+ '',
168
+ ].join('\n')
169
+ let content = ''
170
+ if (existsSync(patchPath)) content = readFileSync(patchPath, 'utf8')
171
+ if (!content.includes('ui-multi-wall')) {
172
+ writeFileSync(patchPath, content.trimEnd() + '\n' + insert, 'utf8')
173
+ process.stdout.write(`patched ${patchPath}\n`)
174
+ } else {
175
+ process.stdout.write(`profile patch already enables ui-multi-wall (${patchPath})\n`)
176
+ }
177
+
178
+ process.stdout.write('\nDone. Restart dsh web to load the wall:\n')
179
+ process.stdout.write(' dsh web --port <n>\n')
180
+ }
181
+
182
+ /* ------------------------------------------------------------------ */
183
+ /* start */
184
+ /* ------------------------------------------------------------------ */
185
+
186
+ const START_HELP = `dsh-multi-chat start — start N loopback DSH web instances
187
+
188
+ Usage:
189
+ dsh-multi-chat start [--ports 3080,3081,3082,3083] [--remote] [--token <secret>]
190
+ [--gateway-ports 8080,8081,8082,8083] [--no-open]
191
+
192
+ Options:
193
+ --ports <list> comma-separated loopback instance ports (default 3080..3083)
194
+ --remote put an authenticated gateway (0.0.0.0) in front of
195
+ every instance for phone/remote access
196
+ --token <secret> gateway login token; required with --remote (a random
197
+ one is generated and printed when omitted)
198
+ --gateway-ports <list> external gateway ports; default instance-port + 5000
199
+ --tls-cert/--tls-key PEM files; enables HTTPS on every gateway
200
+ --name <label> gateway login page label (default DSH)
201
+ --no-open do not open the browser
202
+ --help show this help
203
+
204
+ Examples:
205
+ dsh-multi-chat start --ports 3080,3081
206
+ dsh-multi-chat start --remote --token hunter2 --ports 3080,3081 --gateway-ports 8440,8441
207
+ `
208
+
209
+ async function cmdStart(args) {
210
+ const opts = {
211
+ ports: '3080,3081,3082,3083',
212
+ remote: false,
213
+ token: null,
214
+ gatewayPorts: null,
215
+ tlsCert: null,
216
+ tlsKey: null,
217
+ name: 'DSH',
218
+ noOpen: false,
219
+ }
220
+ for (let i = 0; i < args.length; i++) {
221
+ const a = args[i]
222
+ switch (a) {
223
+ case '--ports': opts.ports = args[++i] ?? opts.ports; break
224
+ case '--remote': opts.remote = true; break
225
+ case '--token': opts.token = args[++i] ?? null; break
226
+ case '--gateway-ports': opts.gatewayPorts = args[++i] ?? null; break
227
+ case '--tls-cert': opts.tlsCert = args[++i] ?? null; break
228
+ case '--tls-key': opts.tlsKey = args[++i] ?? null; break
229
+ case '--name': opts.name = args[++i] ?? opts.name; break
230
+ case '--no-open': opts.noOpen = true; break
231
+ case '--help': process.stdout.write(START_HELP); return
232
+ default: throw new Error(`unknown start option: ${a}`)
233
+ }
234
+ }
235
+ const ports = opts.ports.split(',').map(p => p.trim()).filter(p => /^\d+$/.test(p)).map(Number)
236
+ if (ports.length === 0) throw new Error(`no valid ports in: ${opts.ports}`)
237
+
238
+ let token = opts.token
239
+ if (opts.remote && token === null) {
240
+ token = Array.from({ length: 4 }, () => Math.random().toString(36).slice(2, 8)).join('')
241
+ process.stdout.write(`[start] generated gateway token: ${token} (save it!)\n`)
242
+ }
243
+
244
+ // Resolve the dsh launcher once (shell on Windows resolves the .cmd shim).
245
+ const dshFile = 'dsh'
246
+ const dshShell = isWin
247
+
248
+ const state = { pid: [], ports: [], gateways: [], startedAt: new Date().toISOString() }
249
+ const started = []
250
+
251
+ for (const port of ports) {
252
+ const pid = spawnDetached(dshFile, ['web', '--port', String(port)], { shell: dshShell })
253
+ state.pid.push(pid)
254
+ state.ports.push(port)
255
+ process.stdout.write(`dsh web --port ${port} (pid ${pid})\n`)
256
+ const ready = await waitReady(port)
257
+ if (!ready) process.stdout.write(` ⚠ instance on :${port} did not answer the DSH shell yet — check its console\n`)
258
+ started.push(port)
259
+ }
260
+
261
+ if (opts.remote) {
262
+ const explicitGatewayPorts = opts.gatewayPorts !== null
263
+ ? opts.gatewayPorts.split(',').map(p => p.trim()).filter(p => /^\d+$/.test(p)).map(Number)
264
+ : null
265
+ if (explicitGatewayPorts !== null && explicitGatewayPorts.length < ports.length) {
266
+ throw new Error(`--gateway-ports needs one port per instance port (got ${explicitGatewayPorts.length} for ${ports.length})`)
267
+ }
268
+ const gatewayFile = join(ROOT, 'scripts', 'gateway.mjs')
269
+ if (!existsSync(gatewayFile)) throw new Error(`gateway script not found: ${gatewayFile}`)
270
+ const usedGatewayPorts = new Set()
271
+ for (let i = 0; i < ports.length; i++) {
272
+ const port = ports[i]
273
+ // Auto-advance past Windows excluded port ranges (Hyper-V/WinNAT) and
274
+ // busy ports so a default +5000 mapping never silently fails to bind;
275
+ // already-assigned ports are skipped so two gateways never collide.
276
+ const desired = explicitGatewayPorts !== null ? explicitGatewayPorts[i] : port + 5000
277
+ const gatewayPort = await reserveGatewayPort(desired, usedGatewayPorts)
278
+ if (gatewayPort !== desired) process.stdout.write(` gateway port ${desired} not bindable — using ${gatewayPort}\n`)
279
+ const gwArgs = ['--target', `127.0.0.1:${port}`, '--listen', `0.0.0.0:${gatewayPort}`, '--token', token, '--name', opts.name]
280
+ if (opts.tlsCert !== null && opts.tlsKey !== null) {
281
+ gwArgs.push('--tls-cert', opts.tlsCert, '--tls-key', opts.tlsKey)
282
+ }
283
+ const pid = spawnDetached(process.execPath, [gatewayFile, ...gwArgs])
284
+ state.gateways.push({ port, gatewayPort, pid })
285
+ process.stdout.write(`gateway :${gatewayPort} -> 127.0.0.1:${port} (pid ${pid})\n`)
286
+ }
287
+ }
288
+
289
+ writeState(state)
290
+ process.stdout.write(`state saved to ${STATE_FILE}\n`)
291
+
292
+ if (opts.remote) {
293
+ const scheme = opts.tlsCert !== null ? 'https' : 'http'
294
+ const ips = lanIps()
295
+ process.stdout.write('\nphone / remote access (token required):\n')
296
+ state.gateways.forEach((g) => {
297
+ const hosts = ips.length > 0 ? ips : ['localhost']
298
+ process.stdout.write(` ${hosts.map(ip => `${scheme}://${ip}:${g.gatewayPort}/`).join(' ')} -> dsh :${g.port}\n`)
299
+ })
300
+ process.stdout.write(` token: ${token}\n`)
301
+ } else if (!opts.noOpen && isWin) {
302
+ spawn('cmd', ['/c', 'start', '', `http://127.0.0.1:${started[0]}`], { stdio: 'ignore', detached: true }).unref()
303
+ }
304
+ }
305
+
306
+ function lanIps() {
307
+ const out = []
308
+ for (const ifaces of Object.values(networkInterfaces())) {
309
+ for (const iface of ifaces ?? []) {
310
+ if (iface.family === 'IPv4' && !iface.internal) out.push(iface.address)
311
+ }
312
+ }
313
+ return out
314
+ }
315
+
316
+ /** Whether a TCP port can be bound (catches Windows excluded ranges like
317
+ * Hyper-V/WinNAT's 8017–8116 and busy ports). */
318
+ function canBind(port) {
319
+ return new Promise(resolve => {
320
+ const srv = createNetServer()
321
+ srv.once('error', () => resolve(false))
322
+ srv.listen(port, '127.0.0.1', () => { srv.close(() => resolve(true)) })
323
+ })
324
+ }
325
+
326
+ /** Pick the first bindable port at or after `desired` that is not already
327
+ * assigned to another gateway in this run (probes up to 500 ports). */
328
+ async function reserveGatewayPort(desired, used) {
329
+ for (let candidate = desired; candidate < desired + 500; candidate++) {
330
+ if (used.has(candidate)) continue
331
+ if (await canBind(candidate)) {
332
+ used.add(candidate)
333
+ return candidate
334
+ }
335
+ }
336
+ throw new Error(`no bindable gateway port near ${desired} (Windows excluded ranges or busy ports)`)
337
+ }
338
+
339
+ /* ------------------------------------------------------------------ */
340
+ /* stop */
341
+ /* ------------------------------------------------------------------ */
342
+
343
+ const STOP_HELP = `dsh-multi-chat stop — stop everything \`start\` recorded
344
+
345
+ Usage:
346
+ dsh-multi-chat stop
347
+
348
+ Reads ${STATE_FILE} and terminates the recorded dsh instances and gateways.
349
+ `
350
+
351
+ async function cmdStop() {
352
+ const state = readState()
353
+ if (state === null) {
354
+ process.stdout.write(`no state file at ${STATE_FILE} — nothing to stop\n`)
355
+ return
356
+ }
357
+ let count = 0
358
+ for (const pid of [...(state.pid ?? []), ...(state.gateways ?? []).map(g => g.pid)]) {
359
+ if (typeof pid !== 'number') continue
360
+ killPid(pid)
361
+ count++
362
+ process.stdout.write(`stopped pid ${pid}\n`)
363
+ }
364
+ rmSync(STATE_FILE, { force: true })
365
+ process.stdout.write(`stopped ${count} process(es); removed ${STATE_FILE}\n`)
366
+ }
367
+
368
+ /* ------------------------------------------------------------------ */
369
+ /* gateway */
370
+ /* ------------------------------------------------------------------ */
371
+
372
+ async function cmdGateway(args) {
373
+ const script = join(ROOT, 'scripts', 'gateway.mjs')
374
+ if (!existsSync(script)) throw new Error(`gateway script not found: ${script}`)
375
+ const child = spawn(process.execPath, [script, ...args], { stdio: 'inherit' })
376
+ child.on('exit', (code) => process.exit(code ?? 1))
377
+ }
378
+
379
+ /* ------------------------------------------------------------------ */
380
+ /* Main. */
381
+ /* ------------------------------------------------------------------ */
382
+
383
+ const MAIN_HELP = `dsh-multi-chat — multi-window wall + authenticated remote access for DSH
384
+
385
+ Usage:
386
+ dsh-multi-chat install [--profile web] install the wall plugin
387
+ dsh-multi-chat start [options] start instances (+ gateways with --remote)
388
+ dsh-multi-chat stop stop what start recorded
389
+ dsh-multi-chat gateway [options] run the authenticated gateway
390
+ dsh-multi-chat --help this help
391
+
392
+ Run "dsh-multi-chat <cmd> --help" for command details.
393
+ `
394
+
395
+ async function main() {
396
+ const [cmd, ...rest] = process.argv.slice(2)
397
+ try {
398
+ switch (cmd) {
399
+ case 'install': await cmdInstall(rest); break
400
+ case 'start': await cmdStart(rest); break
401
+ case 'stop': await cmdStop(); break
402
+ case 'gateway': await cmdGateway(rest); break
403
+ case '--help':
404
+ case '-h':
405
+ case undefined:
406
+ process.stdout.write(MAIN_HELP)
407
+ break
408
+ default:
409
+ throw new Error(`unknown command: ${cmd}`)
410
+ }
411
+ } catch (error) {
412
+ process.stderr.write(`[dsh-multi-chat] ${error instanceof Error ? error.message : String(error)}\n`)
413
+ process.exit(1)
414
+ }
415
+ }
416
+
417
+ void main()
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "dsh-multi-chat",
3
+ "version": "0.4.0",
4
+ "description": "DSH 多对话:在官方 DeepSeek Harness Web 界面里并排运行和监控多个对话实例(多窗口墙),并内置带口令认证的手机访问网关。npx dsh-multi-chat install|start|stop",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "dsh-multi-chat": "bin/dsh-multi-chat.mjs"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/daetz-coder/dsh-multi-chat.git"
13
+ },
14
+ "homepage": "https://github.com/daetz-coder/dsh-multi-chat#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/daetz-coder/dsh-multi-chat/issues"
17
+ },
18
+ "keywords": [
19
+ "dsh",
20
+ "deepseek-harness",
21
+ "deepseek",
22
+ "multi-chat",
23
+ "multi-conversation",
24
+ "client-plugin",
25
+ "gateway",
26
+ "phone-access"
27
+ ],
28
+ "files": [
29
+ "bin/",
30
+ "scripts/",
31
+ "patches/",
32
+ "plugin/dsh-client-ui-multi-wall/package.json",
33
+ "plugin/dsh-client-ui-multi-wall/README.md",
34
+ "plugin/dsh-client-ui-multi-wall/lib/index.js",
35
+ "plugin/dsh-client-ui-multi-wall/lib/invariant.js",
36
+ "plugin/dsh-client-ui-multi-wall/lib/client.js",
37
+ "plugin/dsh-client-ui-multi-wall/lib/types/**/*.d.ts"
38
+ ],
39
+ "scripts": {
40
+ "test": "cd harness-src && npx vitest run packages/client/ui-multi-wall"
41
+ },
42
+ "engines": {
43
+ "node": ">=20"
44
+ }
45
+ }
@@ -0,0 +1,10 @@
1
+ # Enable the multi-window wall inside an existing `dsh web` instance.
2
+ # Paste the insert below into the web profile's patch layer
3
+ # ($DSH_HOME/profiles/web/cordis.patch.yml), or pass via `--patch`:
4
+ # dsh web --port <n> --patch ./patches/multi-wall.yml
5
+ #
6
+ # The package must be installed in the profile's node_modules first:
7
+ # dsh plugin --profile web add <path-to-tarball-or-git-url>
8
+ - insert:
9
+ - id: ui-multi-wall
10
+ name: '@deepseek-ai/dsh-client-ui-multi-wall'