dsh-multi-chat 1.0.5 → 1.0.7
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 +40 -2
- package/README.zh.md +40 -2
- package/bin/dsh-multi-chat.mjs +117 -11
- package/lib/index.js +54 -9
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/lib/types/index.d.ts +12 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +49 -8
- package/lib/types/index.js.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +64 -8
package/README.md
CHANGED
|
@@ -49,7 +49,8 @@ Install a **multi-window wall** into the official [DeepSeek Harness (DSH)](https
|
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
51
|
# 1. Install — ONE command straight from the npm registry (no CLI to download):
|
|
52
|
-
dsh plugin --profile web add dsh-multi-chat
|
|
52
|
+
dsh plugin --profile web add dsh-multi-chat # for `dsh web`
|
|
53
|
+
dsh plugin --profile desktop add dsh-multi-chat # for the Desktop app
|
|
53
54
|
|
|
54
55
|
# …or via the plugin's own npx CLI (packs a tarball and installs it):
|
|
55
56
|
npx dsh-multi-chat install
|
|
@@ -114,6 +115,29 @@ dsh plugin --profile web remove dsh-multi-chat
|
|
|
114
115
|
3. Inside the wall view: auto-discovery (own port excluded), column switching (auto/1/2/3/4/6, horizontally filled by default), click title to maximize, ⟳ refresh one, ↗ open in a new tab, ✕ remove from view, refresh all, and live online status dots. The layout is persisted to `localStorage`.
|
|
115
116
|
4. To exit the wall, click the **"Exit" button in the toolbar's top-right** to switch back to the chat view in one click.
|
|
116
117
|
|
|
118
|
+
## Launch tokens (DSH 0.2)
|
|
119
|
+
|
|
120
|
+
Since DSH 0.2, every `dsh web` process mints a **launch token**, answers an unauthenticated `/` with **401**, and only then serves the shell. An unauthenticated liveness probe therefore sees every real instance as dead, and the wall stays empty.
|
|
121
|
+
|
|
122
|
+
The wall presents the token automatically. `dsh-multi-chat start` reads each instance's token off its console and records it in `$DSH_HOME/multi-wall-instances.json`, which the node half picks up — no configuration needed:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
dsh-multi-chat start --ports 3080,3081 # tokens captured automatically
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Instances you start **some other way** must be listed by hand, either by editing that same JSON file or via the profile patch:
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
- id: ui-multi-wall
|
|
132
|
+
name: dsh-multi-chat
|
|
133
|
+
config:
|
|
134
|
+
tokens:
|
|
135
|
+
"3085": "<the token dsh web printed>"
|
|
136
|
+
"3086": "<the token dsh web printed>"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Tokens are per-process: restarting an instance mints a new one, so re-read it from that instance's startup output. `dsh-multi-chat start` also keeps each instance's full console log in `$DSH_HOME/multi-wall-logs/instance-<port>.log`.
|
|
140
|
+
|
|
117
141
|
## Phone / remote access (built-in authenticated gateway)
|
|
118
142
|
|
|
119
143
|
The official `dsh web` **deliberately forbids `--host 0.0.0.0`** (it would expose remote code execution to the network). This plugin ships a built-in **token-authenticated intranet gateway**: click the "Phone access" button and it **automatically** starts a gateway for the current instance (listening on `0.0.0.0`, reverse-proxying to `127.0.0.1:<this instance's port>`), returning a LAN URL + login token.
|
|
@@ -134,7 +158,19 @@ Open that URL on your phone and enter the token to reach the full DSH UI. The ga
|
|
|
134
158
|
|
|
135
159
|
## Distribution & install
|
|
136
160
|
|
|
137
|
-
`dsh-multi-chat` is published to npm (unscoped public package) and released on GitHub (source zip/tarball per tag). Every channel below ends in the same three things: the package lands as a dependency of the
|
|
161
|
+
`dsh-multi-chat` is published to npm (unscoped public package) and released on GitHub (source zip/tarball per tag). Every channel below ends in the same three things: the package lands as a dependency of the target profile, DSH reconciles it into the bundle layer stack (the package declares `dsh.bundle.patch`, so its own `cordis.patch.yml` is mounted automatically — no manual patch edits), and a restart of that profile loads the wall.
|
|
162
|
+
|
|
163
|
+
### Which profile? (`web` or `desktop`)
|
|
164
|
+
|
|
165
|
+
Both work, and they run the **same** browser surface with the **same** platform module table:
|
|
166
|
+
|
|
167
|
+
| | `web` (`dsh web`) | `desktop` (the Desktop app) |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| Host half — `/multi/api/*` routes | ✅ needs the `webServer` service | ✅ `dsh-web-app` provides it |
|
|
170
|
+
| Browser half — slot registrations | ✅ | ✅ identical seed table |
|
|
171
|
+
| Marketplace listing | ✅ | ✅ |
|
|
172
|
+
|
|
173
|
+
Swap `--profile web` for `--profile desktop` in any command below. `dshmarket` is the precedent: it ships the same `dsh.client.platform: "web"` manifest and runs in both.
|
|
138
174
|
|
|
139
175
|
### Install / uninstall cheat-sheet
|
|
140
176
|
|
|
@@ -146,6 +182,8 @@ Open that URL on your phone and enter the token to reach the full DSH UI. The ga
|
|
|
146
182
|
| **Tarball (offline)** | `npm pack` → `dsh plugin --profile web add ./dsh-multi-chat-*.tgz` | `dsh plugin --profile web remove dsh-multi-chat` |
|
|
147
183
|
| **Git clone** | `node bin/dsh-multi-chat.mjs install` | `dsh plugin --profile web remove dsh-multi-chat` |
|
|
148
184
|
|
|
185
|
+
> Every row takes `--profile desktop` as well; see [Which profile?](#which-profile-web-or-desktop).
|
|
186
|
+
|
|
149
187
|
> `dsh plugin --profile web remove dsh-multi-chat` is the **single uninstall
|
|
150
188
|
> command for every channel**: it removes the dependency from the profile, and
|
|
151
189
|
> DSH strips it from the `dsh.profile.bundles` layer stack automatically
|
package/README.zh.md
CHANGED
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
|
|
50
50
|
```bash
|
|
51
51
|
# 1. 安装 —— 从 npm 官方源一条命令搞定(无需下载任何 CLI):
|
|
52
|
-
dsh plugin --profile web add dsh-multi-chat
|
|
52
|
+
dsh plugin --profile web add dsh-multi-chat # 适用于 dsh web
|
|
53
|
+
dsh plugin --profile desktop add dsh-multi-chat # 适用于桌面版应用
|
|
53
54
|
|
|
54
55
|
# ……或通过插件自带的 npx CLI(先打包再安装):
|
|
55
56
|
npx dsh-multi-chat install
|
|
@@ -114,6 +115,29 @@ dsh plugin --profile web remove dsh-multi-chat
|
|
|
114
115
|
3. 墙视图内:自动发现实例(自动排除自身端口)、列数切换(自动/1/2/3/4/6,默认横向铺满)、点标题放大、⟳ 单独刷新、↗ 新标签页打开、✕ 从视图移除、全部刷新、实时在线状态点。布局保存在 localStorage。
|
|
115
116
|
4. 退出墙:点工具栏**右上角的「退出」按钮**,一键切回对话视图。
|
|
116
117
|
|
|
118
|
+
## 启动令牌(DSH 0.2)
|
|
119
|
+
|
|
120
|
+
从 DSH 0.2 起,每个 `dsh web` 进程会生成一个**启动令牌(launch token)**,不带令牌的 `/` 请求会得到 **401**,只有带上令牌才返回界面。因此不带令牌的探活会把**每一个真实运行中的实例**都判成离线,墙就会一直是空的。
|
|
121
|
+
|
|
122
|
+
墙会自动带上这个令牌。用 `dsh-multi-chat start` 启动时,它会从各实例的控制台读到令牌并记录到 `$DSH_HOME/multi-wall-instances.json`,node 半边会自动读取,无需任何配置:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
dsh-multi-chat start --ports 3080,3081 # 令牌自动捕获
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
用**其他方式**启动的实例需要手动登记 —— 可以直接编辑上面那个 JSON 文件,或者在 profile 的 patch 层里写:
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
- id: ui-multi-wall
|
|
132
|
+
name: dsh-multi-chat
|
|
133
|
+
config:
|
|
134
|
+
tokens:
|
|
135
|
+
"3085": "<dsh web 启动时打印的令牌>"
|
|
136
|
+
"3086": "<dsh web 启动时打印的令牌>"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
令牌是**每进程**的:重启实例会生成新的,记得从该实例的启动输出里重新读取。`dsh-multi-chat start` 同时会把每个实例的完整控制台日志留在 `$DSH_HOME/multi-wall-logs/instance-<port>.log`。
|
|
140
|
+
|
|
117
141
|
## 手机 / 远程访问(内置认证网关)
|
|
118
142
|
|
|
119
143
|
官方 `dsh web` 出于安全**刻意禁止 `--host 0.0.0.0`**(会向网络暴露远程代码执行)。本插件内置了一个**带令牌认证的内联网关**:点工具栏「手机访问」按钮,它会**自动**为本实例启动一个网关(监听 `0.0.0.0`,反向代理到 `127.0.0.1:<本实例端口>`),并返回局域网 URL + 登录口令。
|
|
@@ -134,7 +158,19 @@ dsh plugin --profile web remove dsh-multi-chat
|
|
|
134
158
|
|
|
135
159
|
## 分发与安装
|
|
136
160
|
|
|
137
|
-
`dsh-multi-chat` 已发布到 npm(无作用域公开包),并在 GitHub 按 tag 发布 Release(源码 zip/tarball
|
|
161
|
+
`dsh-multi-chat` 已发布到 npm(无作用域公开包),并在 GitHub 按 tag 发布 Release(源码 zip/tarball)。下面每条渠道殊途同归:包成为目标 profile 的依赖 → DSH 自动调和进 bundle 层栈(包声明了 `dsh.bundle.patch`,自带 `cordis.patch.yml` 自动挂载,无需手改任何文件)→ 重启该 profile 生效。
|
|
162
|
+
|
|
163
|
+
### 用哪个 profile?(`web` 还是 `desktop`)
|
|
164
|
+
|
|
165
|
+
两者都支持,而且跑的是**同一套**浏览器界面、**同一张**平台模块表:
|
|
166
|
+
|
|
167
|
+
| | `web`(`dsh web`) | `desktop`(桌面版应用) |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| 宿主侧 —— `/multi/api/*` 路由 | ✅ 依赖 `webServer` 服务 | ✅ `dsh-web-app` 已提供 |
|
|
170
|
+
| 浏览器侧 —— slot 注册 | ✅ | ✅ seed 表完全相同 |
|
|
171
|
+
| 市场列表展示 | ✅ | ✅ |
|
|
172
|
+
|
|
173
|
+
下面任何命令把 `--profile web` 换成 `--profile desktop` 即可。可参照的先例是 `dshmarket`:它同样声明 `dsh.client.platform: "web"`,却在两端都正常运行。
|
|
138
174
|
|
|
139
175
|
### 安装 / 卸载速查表
|
|
140
176
|
|
|
@@ -146,6 +182,8 @@ dsh plugin --profile web remove dsh-multi-chat
|
|
|
146
182
|
| **Tarball(离线)** | `npm pack` → `dsh plugin --profile web add ./dsh-multi-chat-*.tgz` | `dsh plugin --profile web remove dsh-multi-chat` |
|
|
147
183
|
| **Git clone** | `node bin/dsh-multi-chat.mjs install` | `dsh plugin --profile web remove dsh-multi-chat` |
|
|
148
184
|
|
|
185
|
+
> 上表每一行都可以换成 `--profile desktop`;详见[用哪个 profile?](#用哪个-profileweb-还是-desktop)。
|
|
186
|
+
|
|
149
187
|
> `dsh plugin --profile web remove dsh-multi-chat` 是**所有渠道统一的卸载命令**:
|
|
150
188
|
> 它从 profile 移除依赖,DSH 会自动把它从 `dsh.profile.bundles` 层栈里剔除
|
|
151
189
|
> (层栈按已安装依赖实时调和,详见 `dsh plugin --help`)。之后重启 `dsh web` 卸载生效。
|
package/bin/dsh-multi-chat.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { spawn, spawnSync } from 'node:child_process'
|
|
24
24
|
import { createServer as createNetServer } from 'node:net'
|
|
25
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
|
26
26
|
import { homedir, networkInterfaces } from 'node:os'
|
|
27
27
|
import { join, dirname } from 'node:path'
|
|
28
28
|
import { fileURLToPath } from 'node:url'
|
|
@@ -62,9 +62,10 @@ function dshHome() {
|
|
|
62
62
|
|
|
63
63
|
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) }
|
|
64
64
|
|
|
65
|
-
async function probe(port, timeoutMs = 600) {
|
|
65
|
+
async function probe(port, token = null, timeoutMs = 600) {
|
|
66
|
+
const url = token === null ? `http://127.0.0.1:${port}/` : `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`
|
|
66
67
|
try {
|
|
67
|
-
const res = await fetch(
|
|
68
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
68
69
|
const body = await res.text()
|
|
69
70
|
return res.status === 200 && body.includes('__DSH_BOOT__')
|
|
70
71
|
} catch {
|
|
@@ -72,19 +73,27 @@ async function probe(port, timeoutMs = 600) {
|
|
|
72
73
|
}
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
async function waitReady(port, timeoutMs =
|
|
76
|
+
async function waitReady(port, token = null, timeoutMs = 60000) {
|
|
76
77
|
const deadline = Date.now() + timeoutMs
|
|
77
78
|
for (;;) {
|
|
78
|
-
|
|
79
|
+
// A cold instance accepts the connection but needs noticeably longer than
|
|
80
|
+
// a warm one to assemble index.html, so the readiness probe gets its own
|
|
81
|
+
// generous budget; `probe`'s 600ms default is tuned for the wall's liveness
|
|
82
|
+
// polls against already-running instances, not for boot detection.
|
|
83
|
+
if (await probe(port, token, 10000)) return true
|
|
79
84
|
if (Date.now() > deadline) return false
|
|
80
85
|
await sleep(400)
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
88
|
|
|
84
|
-
function spawnDetached(file, args, { shell = false } = {}) {
|
|
89
|
+
function spawnDetached(file, args, { shell = false, logFile = null } = {}) {
|
|
90
|
+
// A log file is requested by handing `spawn` the file descriptors, not with
|
|
91
|
+
// the shell's `>` syntax: a DETACHED shell does not pass our stdio handles on
|
|
92
|
+
// to the process it wraps, so the redirect silently produces an empty file.
|
|
93
|
+
const stdio = logFile === null ? 'ignore' : ['ignore', openSync(logFile, 'a'), openSync(logFile, 'a')]
|
|
85
94
|
const child = spawn(file, args, {
|
|
86
95
|
detached: true,
|
|
87
|
-
stdio
|
|
96
|
+
stdio,
|
|
88
97
|
windowsHide: true,
|
|
89
98
|
shell,
|
|
90
99
|
})
|
|
@@ -92,6 +101,76 @@ function spawnDetached(file, args, { shell = false } = {}) {
|
|
|
92
101
|
return child.pid
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the `dsh` CLI's JavaScript entry so an instance can be launched as
|
|
106
|
+
* `node <entry> web --port <n>` with no shell in between.
|
|
107
|
+
*
|
|
108
|
+
* Two reasons this is not optional: on Windows Node refuses to execute the
|
|
109
|
+
* `dsh.cmd` shim without a shell, and once a shell IS involved the detached
|
|
110
|
+
* child never receives the stdio handles carrying the launch token. Spawning
|
|
111
|
+
* the entry directly gives the instance an inherited log handle, which is how
|
|
112
|
+
* `readLaunchToken` below gets to see the token.
|
|
113
|
+
* @returns the absolute path to the CLI entry, or null when it cannot be found.
|
|
114
|
+
*/
|
|
115
|
+
function dshCliEntry() {
|
|
116
|
+
const lookup = spawnSync(isWin ? 'where' : 'which', ['dsh'], { encoding: 'utf8' })
|
|
117
|
+
const shim = (lookup.stdout ?? '').split(/\r?\n/).map(line => line.trim()).find(Boolean)
|
|
118
|
+
if (shim === undefined) return null
|
|
119
|
+
const candidates = isWin
|
|
120
|
+
// <npm-bin>\dsh.cmd -> <npm-bin>\node_modules\@deepseek-ai\dsh\lib\bin.js
|
|
121
|
+
? [join(dirname(shim), 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')]
|
|
122
|
+
// <prefix>/bin/dsh is a symlink to .../lib/node_modules/@deepseek-ai/dsh/lib/bin.js
|
|
123
|
+
: [
|
|
124
|
+
(() => { try { return join(realpathSync(shim), '..', 'bin.js') } catch { return '' } })(),
|
|
125
|
+
join(dirname(shim), '..', 'lib', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js'),
|
|
126
|
+
]
|
|
127
|
+
return candidates.find(candidate => candidate !== '' && existsSync(candidate)) ?? null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Each `dsh web` process mints one launch token at boot and prints it as
|
|
132
|
+
* `dsh web: http://127.0.0.1:<port>/?token=<tok>`. Since DSH 0.2 every
|
|
133
|
+
* instance answers an unauthenticated `/` with 401, so the wall's liveness
|
|
134
|
+
* probe must present this token or it will mark every instance dead.
|
|
135
|
+
*
|
|
136
|
+
* The token is per-process and is never persisted by DSH, so we read it off
|
|
137
|
+
* the instance's own console output — hence the log file: piping stdout would
|
|
138
|
+
* leave the detached child writing into a pipe nobody drains once this
|
|
139
|
+
* process exits.
|
|
140
|
+
*/
|
|
141
|
+
const LAUNCH_TOKEN_RE = /\/\?token=([A-Za-z0-9_-]+)/
|
|
142
|
+
|
|
143
|
+
async function readLaunchToken(logFile, port, timeoutMs = 30000) {
|
|
144
|
+
const deadline = Date.now() + timeoutMs
|
|
145
|
+
const needle = `127.0.0.1:${port}/?token=`
|
|
146
|
+
for (;;) {
|
|
147
|
+
let text = ''
|
|
148
|
+
try { text = readFileSync(logFile, 'utf8') } catch { /* not created yet */ }
|
|
149
|
+
if (text !== '') {
|
|
150
|
+
// Match only this port's line: every instance shares one log-free
|
|
151
|
+
// console, and another port's token would authenticate nothing.
|
|
152
|
+
const line = text.split(/\r?\n/).find(l => l.includes(needle))
|
|
153
|
+
const match = line === undefined ? null : LAUNCH_TOKEN_RE.exec(line)
|
|
154
|
+
if (match !== null) return match[1]
|
|
155
|
+
}
|
|
156
|
+
if (Date.now() > deadline) return null
|
|
157
|
+
await sleep(400)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The wall runs inside the profile's copy of this package while the CLI may
|
|
163
|
+
* run from an npx cache, so a package-relative file would never be shared.
|
|
164
|
+
* `$DSH_HOME` is the one location both sides resolve identically.
|
|
165
|
+
*/
|
|
166
|
+
const INSTANCES_FILE = () => join(dshHome(), 'multi-wall-instances.json')
|
|
167
|
+
|
|
168
|
+
function writeInstanceTokens(tokens) {
|
|
169
|
+
const file = INSTANCES_FILE()
|
|
170
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
171
|
+
writeFileSync(file, JSON.stringify({ updatedAt: new Date().toISOString(), tokens }, null, 2), 'utf8')
|
|
172
|
+
}
|
|
173
|
+
|
|
95
174
|
function killPid(pid) {
|
|
96
175
|
if (isWin) {
|
|
97
176
|
spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'], { stdio: 'ignore' })
|
|
@@ -254,23 +333,50 @@ async function cmdStart(args) {
|
|
|
254
333
|
process.stdout.write(`[start] generated gateway token: ${token} (save it!)\n`)
|
|
255
334
|
}
|
|
256
335
|
|
|
257
|
-
// Resolve the dsh launcher once
|
|
336
|
+
// Resolve the dsh launcher once. The shell form only resolves the .cmd shim;
|
|
337
|
+
// it is the fallback for when the CLI entry cannot be located.
|
|
258
338
|
const dshFile = 'dsh'
|
|
259
339
|
const dshShell = isWin
|
|
340
|
+
const dshEntry = dshCliEntry()
|
|
260
341
|
|
|
261
342
|
const state = { pid: [], ports: [], gateways: [], startedAt: new Date().toISOString() }
|
|
262
343
|
const started = []
|
|
263
344
|
|
|
345
|
+
const tokens = {}
|
|
346
|
+
const logDir = join(dshHome(), 'multi-wall-logs')
|
|
347
|
+
mkdirSync(logDir, { recursive: true })
|
|
348
|
+
|
|
264
349
|
for (const port of ports) {
|
|
265
|
-
const
|
|
350
|
+
const logFile = join(logDir, `instance-${port}.log`)
|
|
351
|
+
// Prefer the shell-free form: only then does the instance inherit the log
|
|
352
|
+
// handle and its launch token become readable to `readLaunchToken`.
|
|
353
|
+
const pid = dshEntry === null
|
|
354
|
+
? spawnDetached(dshFile, ['web', '--port', String(port)], { shell: dshShell, logFile })
|
|
355
|
+
: spawnDetached(process.execPath, [dshEntry, 'web', '--port', String(port)], { logFile })
|
|
266
356
|
state.pid.push(pid)
|
|
267
357
|
state.ports.push(port)
|
|
268
358
|
process.stdout.write(`dsh web --port ${port} (pid ${pid})\n`)
|
|
269
|
-
|
|
270
|
-
|
|
359
|
+
if (dshEntry === null) {
|
|
360
|
+
process.stdout.write(' ⚠ dsh CLI entry not resolvable; launch-token capture unavailable\n')
|
|
361
|
+
}
|
|
362
|
+
// Read the launch token before probing: an unauthenticated request answers
|
|
363
|
+
// 401, which would report every freshly started instance as dead.
|
|
364
|
+
const token = await readLaunchToken(logFile, port)
|
|
365
|
+
if (token !== null) tokens[port] = token
|
|
366
|
+
const ready = await waitReady(port, token, 60000)
|
|
367
|
+
if (!ready) {
|
|
368
|
+
process.stdout.write(` ⚠ instance on :${port} did not answer the DSH shell yet — check ${logFile}\n`)
|
|
369
|
+
} else {
|
|
370
|
+
process.stdout.write(` ✓ ready${token === null ? ' (no launch token printed)' : ' (launch token captured)'}\n`)
|
|
371
|
+
}
|
|
271
372
|
started.push(port)
|
|
272
373
|
}
|
|
273
374
|
|
|
375
|
+
if (Object.keys(tokens).length > 0) {
|
|
376
|
+
writeInstanceTokens(tokens)
|
|
377
|
+
process.stdout.write(`instance tokens saved to ${INSTANCES_FILE()}\n`)
|
|
378
|
+
}
|
|
379
|
+
|
|
274
380
|
if (opts.remote) {
|
|
275
381
|
const explicitGatewayPorts = opts.gatewayPorts !== null
|
|
276
382
|
? opts.gatewayPorts.split(',').map(p => p.trim()).filter(p => /^\d+$/.test(p)).map(Number)
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
2
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import { existsSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
|
|
4
|
-
import { networkInterfaces } from "node:os";
|
|
4
|
+
import { homedir, networkInterfaces } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
5
6
|
import z from "@deepseek-ai/schemastery";
|
|
6
7
|
import { connect, createServer } from "node:net";
|
|
7
8
|
|
|
@@ -491,10 +492,37 @@ const Config = z.object({
|
|
|
491
492
|
ports: z.array(z.natural()).default([]),
|
|
492
493
|
publicUrl: z.string().default(""),
|
|
493
494
|
gatewayPort: z.number().default(0),
|
|
494
|
-
gatewayToken: z.string().default("")
|
|
495
|
+
gatewayToken: z.string().default(""),
|
|
496
|
+
tokens: z.dict(z.string()).default({})
|
|
495
497
|
});
|
|
496
498
|
/** MIME for JSON probe answers. */
|
|
497
499
|
const JSON_TYPE = "application/json; charset=utf-8";
|
|
500
|
+
/** Where `dsh-multi-chat start` records the tokens it captured. */
|
|
501
|
+
const INSTANCE_TOKENS_FILE = "multi-wall-instances.json";
|
|
502
|
+
/**
|
|
503
|
+
|
|
504
|
+
* Launch tokens published by `dsh-multi-chat start`, keyed by port.
|
|
505
|
+
|
|
506
|
+
*
|
|
507
|
+
|
|
508
|
+
* `$DSH_HOME` is the only path both the CLI (which may run from an npx cache)
|
|
509
|
+
|
|
510
|
+
* and this plugin (installed inside the profile) resolve identically, so the
|
|
511
|
+
|
|
512
|
+
* handoff has to live there rather than beside either package.
|
|
513
|
+
|
|
514
|
+
*/
|
|
515
|
+
function launchTokensFromFile() {
|
|
516
|
+
const home = process.env.DSH_HOME !== void 0 && process.env.DSH_HOME !== "" ? process.env.DSH_HOME : join(homedir(), ".dsh");
|
|
517
|
+
try {
|
|
518
|
+
const parsed = JSON.parse(readFileSync(join(home, INSTANCE_TOKENS_FILE), "utf8"));
|
|
519
|
+
const tokens = {};
|
|
520
|
+
for (const [port, token] of Object.entries(parsed.tokens ?? {})) if (/^\d+$/.test(port) && typeof token === "string" && token !== "") tokens[port] = token;
|
|
521
|
+
return tokens;
|
|
522
|
+
} catch {
|
|
523
|
+
return {};
|
|
524
|
+
}
|
|
525
|
+
}
|
|
498
526
|
/** GET one local URL with a short timeout; resolve {status, body} or reject. */
|
|
499
527
|
async function request(url, timeoutMs = 600) {
|
|
500
528
|
const controller = new AbortController();
|
|
@@ -509,10 +537,23 @@ async function request(url, timeoutMs = 600) {
|
|
|
509
537
|
clearTimeout(timer);
|
|
510
538
|
}
|
|
511
539
|
}
|
|
512
|
-
/**
|
|
513
|
-
|
|
540
|
+
/**
|
|
541
|
+
|
|
542
|
+
* Is this local port a live DSH instance?
|
|
543
|
+
|
|
544
|
+
*
|
|
545
|
+
|
|
546
|
+
* DSH 0.2 requires the per-process launch token: `/` answers 401 without it,
|
|
547
|
+
|
|
548
|
+
* which would make every real instance look dead. When a token is known the
|
|
549
|
+
|
|
550
|
+
* probe presents it; 200 + `__DSH_BOOT__` then means alive as before.
|
|
551
|
+
|
|
552
|
+
*/
|
|
553
|
+
async function probePort(port, token) {
|
|
554
|
+
const url = token === void 0 || token === "" ? `http://127.0.0.1:${port}/` : `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`;
|
|
514
555
|
try {
|
|
515
|
-
const { status, body } = await request(
|
|
556
|
+
const { status, body } = await request(url);
|
|
516
557
|
return {
|
|
517
558
|
port,
|
|
518
559
|
alive: status === 200 && body.includes("__DSH_BOOT__"),
|
|
@@ -527,10 +568,10 @@ async function probePort(port) {
|
|
|
527
568
|
}
|
|
528
569
|
}
|
|
529
570
|
/** Concurrent probe of many ports (bounded chunking). */
|
|
530
|
-
async function probePorts(ports) {
|
|
571
|
+
async function probePorts(ports, tokens = {}) {
|
|
531
572
|
const CHUNK = 16;
|
|
532
573
|
const out = [];
|
|
533
|
-
for (let i = 0; i < ports.length; i += CHUNK) out.push(...await Promise.all(ports.slice(i, i + CHUNK).map((port) => probePort(port))));
|
|
574
|
+
for (let i = 0; i < ports.length; i += CHUNK) out.push(...await Promise.all(ports.slice(i, i + CHUNK).map((port) => probePort(port, tokens[String(port)]))));
|
|
534
575
|
return out;
|
|
535
576
|
}
|
|
536
577
|
/** Send a small JSON response. */
|
|
@@ -1062,6 +1103,10 @@ function apply(ctx, config = {}) {
|
|
|
1062
1103
|
const fixedPorts = config.ports ?? [];
|
|
1063
1104
|
let gateway = null;
|
|
1064
1105
|
let gatewayTargetPort = -1;
|
|
1106
|
+
const tokensFor = () => ({
|
|
1107
|
+
...launchTokensFromFile(),
|
|
1108
|
+
...config.tokens ?? {}
|
|
1109
|
+
});
|
|
1065
1110
|
ctx.effect(() => ctx.webServer.register({
|
|
1066
1111
|
kind: "exact",
|
|
1067
1112
|
path: "/multi/api/ports",
|
|
@@ -1080,7 +1125,7 @@ function apply(ctx, config = {}) {
|
|
|
1080
1125
|
const hi = Number.isInteger(qTo) ? qTo : scanTo;
|
|
1081
1126
|
const ports = fixedPorts.length > 0 ? [...fixedPorts] : [];
|
|
1082
1127
|
if (fixedPorts.length === 0) for (let p = lo; p <= hi; p++) ports.push(p);
|
|
1083
|
-
probePorts(ports).then((results) => {
|
|
1128
|
+
probePorts(ports, tokensFor()).then((results) => {
|
|
1084
1129
|
json(res, { ports: results.filter((row) => row.alive) });
|
|
1085
1130
|
}).catch(() => json(res, { ports: [] }, 500));
|
|
1086
1131
|
}
|
|
@@ -1096,7 +1141,7 @@ function apply(ctx, config = {}) {
|
|
|
1096
1141
|
}
|
|
1097
1142
|
const url = new URL(req.url ?? "/", "http://x");
|
|
1098
1143
|
const ports = (url.searchParams.get("ports") ?? "").split(",").map(Number).filter((p) => Number.isInteger(p) && p > 0);
|
|
1099
|
-
probePorts(ports).then((results) => {
|
|
1144
|
+
probePorts(ports, tokensFor()).then((results) => {
|
|
1100
1145
|
json(res, { ports: results });
|
|
1101
1146
|
}).catch(() => json(res, { ports: [] }, 500));
|
|
1102
1147
|
}
|