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 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 web 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 `dsh web` restart loads the wall.
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)。下面每条渠道殊途同归:包成为 web profile 的依赖 → DSH 自动调和进 bundle 层栈(包声明了 `dsh.bundle.patch`,自带 `cordis.patch.yml` 自动挂载,无需手改任何文件)→ 重启 `dsh web` 生效。
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` 卸载生效。
@@ -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(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(timeoutMs) })
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 = 20000) {
76
+ async function waitReady(port, token = null, timeoutMs = 60000) {
76
77
  const deadline = Date.now() + timeoutMs
77
78
  for (;;) {
78
- if (await probe(port)) return true
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: 'ignore',
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 (shell on Windows resolves the .cmd shim).
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 pid = spawnDetached(dshFile, ['web', '--port', String(port)], { shell: dshShell })
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
- const ready = await waitReady(port)
270
- if (!ready) process.stdout.write(` ⚠ instance on :${port} did not answer the DSH shell yet — check its console\n`)
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
- /** Is this local port a live DSH instance (index.html carries __DSH_BOOT__)? */
513
- async function probePort(port) {
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(`http://127.0.0.1:${port}/`);
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
  }