dsh-multi-chat 1.0.2 → 1.0.4

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/src/index.ts CHANGED
@@ -1,618 +1,715 @@
1
- /**
2
- * Multi-window wall plugin, node half: registers the `/multi/api/*` routes
3
- * on the webserver. The browser half fetches these same-origin to discover
4
- * which local ports are live DSH instances, to poll liveness, and to
5
- * terminate a chosen instance (`/multi/api/stop`). The wall itself is pure
6
- * UI — this half answers a few small JSON requests.
7
- * @module dsh-multi-chat
8
- */
9
-
10
- import { execFile, spawn } from 'node:child_process'
11
- import { randomBytes } from 'node:crypto'
12
- import { existsSync } from 'node:fs'
13
- import { networkInterfaces } from 'node:os'
14
- import type { Context } from '@deepseek-ai/cordis'
15
- import z from '@deepseek-ai/schemastery'
16
- import type {} from '@deepseek-ai/dsh-host-webserver'
17
- import { startGateway, type GatewayHandle } from './gateway'
18
-
19
- /** Stable Cordis plugin name. */
20
- export const name = 'dsh-multi-chat'
21
-
22
- /** Services required before the probe routes can be registered. */
23
- export const inject = ['webServer']
24
-
25
- /** Plugin config: the wall's auto-discovery scan range. */
26
- export interface MultiWallConfig {
27
- /** First port of the auto-discovery range. */
28
- scanFrom?: number
29
- /** Last port of the auto-discovery range. */
30
- scanTo?: number
31
- /** Optional fixed port list; when set, discovery ignores the scan range. */
32
- ports?: number[]
33
- /**
34
- * External base URL reported by `/multi/api/link` (e.g. the authenticated
35
- * gateway in front of this loopback instance). When set, the link route
36
- * answers `{ lan: [publicUrl + '/'], reachable: true }`.
37
- */
38
- publicUrl?: string
39
- /**
40
- * Gateway listen port for the inline phone-access gateway. `0` (default)
41
- * means `targetPort + 5000`.
42
- */
43
- gatewayPort?: number
44
- /**
45
- * Optional fixed login token for the inline gateway. Empty (default) means
46
- * a random token is generated per gateway start (returned by /multi/api/link).
47
- */
48
- gatewayToken?: string
49
- }
50
-
51
- /** Schema-validated config (the Loader resolves defaults for absent keys). */
52
- export const Config = z.object({
53
- scanFrom: z.natural().default(3070),
54
- scanTo: z.natural().default(3110),
55
- ports: z.array(z.natural()).default([]),
56
- publicUrl: z.string().default(''),
57
- gatewayPort: z.number().default(0),
58
- gatewayToken: z.string().default(''),
59
- })
60
-
61
- /** MIME for JSON probe answers. */
62
- const JSON_TYPE = 'application/json; charset=utf-8'
63
-
64
- /** One probe result row. */
65
- interface ProbeRow {
66
- port: number
67
- alive: boolean
68
- status: number
69
- }
70
-
71
- /** GET one local URL with a short timeout; resolve {status, body} or reject. */
72
- async function request(url: string, timeoutMs = 600): Promise<{ status: number; body: string }> {
73
- const controller = new AbortController()
74
- const timer = setTimeout(() => controller.abort(), timeoutMs)
75
- try {
76
- const res = await fetch(url, { signal: controller.signal })
77
- return { status: res.status, body: await res.text() }
78
- } finally {
79
- clearTimeout(timer)
80
- }
81
- }
82
-
83
- /** Is this local port a live DSH instance (index.html carries __DSH_BOOT__)? */
84
- async function probePort(port: number): Promise<ProbeRow> {
85
- try {
86
- const { status, body } = await request(`http://127.0.0.1:${port}/`)
87
- return { port, alive: status === 200 && body.includes('__DSH_BOOT__'), status }
88
- } catch {
89
- return { port, alive: false, status: 0 }
90
- }
91
- }
92
-
93
- /** Concurrent probe of many ports (bounded chunking). */
94
- async function probePorts(ports: number[]): Promise<ProbeRow[]> {
95
- const CHUNK = 16
96
- const out: ProbeRow[] = []
97
- for (let i = 0; i < ports.length; i += CHUNK) {
98
- out.push(...(await Promise.all(ports.slice(i, i + CHUNK).map(port => probePort(port)))))
99
- }
100
- return out
101
- }
102
-
103
- /** Send a small JSON response. */
104
- function json(res: import('node:http').ServerResponse, value: unknown, status = 200): void {
105
- res.writeHead(status, { 'content-type': JSON_TYPE, 'cache-control': 'no-store' })
106
- res.end(JSON.stringify(value))
107
- }
108
-
109
- /** One stop result row from /multi/api/stop. */
110
- export interface StopRow {
111
- port: number
112
- ok: boolean
113
- /** Human-readable failure reason (absent on success). */
114
- error?: string
115
- }
116
-
117
- /**
118
- * Run a command and resolve its stdout text. Rejects on non-zero exit.
119
- * @param file - the executable path.
120
- * @param args - CLI arguments.
121
- * @returns the trimmed stdout.
122
- */
123
- function execStdout(file: string, args: string[]): Promise<string> {
124
- return new Promise((resolve, reject) => {
125
- execFile(file, args, { timeout: 5000 }, (error, stdout) => {
126
- if (error !== null) {
127
- reject(error)
128
- return
129
- }
130
- resolve(stdout)
131
- })
132
- })
133
- }
134
-
135
- /**
136
- * Resolve the PIDs listening on a local TCP port. Windows uses `netstat`;
137
- * POSIX uses `lsof` (present on macOS and most Linux installs).
138
- * @param port - the listening port.
139
- * @returns the listener PIDs (possibly empty).
140
- */
141
- async function listeningPids(port: number): Promise<number[]> {
142
- if (process.platform === 'win32') {
143
- const stdout = await execStdout('netstat', ['-ano', '-p', 'tcp'])
144
- const pids = new Set<number>()
145
- for (const line of stdout.split(/\r?\n/)) {
146
- // TCP 127.0.0.1:3080 0.0.0.0:0 LISTENING 12345
147
- const m = /^\s*TCP\s+([0-9.]+|\*|\[::\]):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/.exec(line)
148
- if (m !== null && Number(m[2]) === port) pids.add(Number(m[3]))
149
- }
150
- return [...pids]
151
- }
152
- const stdout = await execStdout('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'])
153
- return stdout.split(/\s+/).map(Number).filter(pid => Number.isInteger(pid) && pid > 0)
154
- }
155
-
156
- /**
157
- * Terminate one PID. Windows uses `taskkill /F` (force); POSIX sends SIGTERM
158
- * then SIGKILL after a grace period.
159
- * @param pid - the process id to terminate.
160
- */
161
- async function killPid(pid: number): Promise<void> {
162
- if (process.platform === 'win32') {
163
- await execStdout('taskkill', ['/PID', String(pid), '/F', '/T'])
164
- return
165
- }
166
- try {
167
- process.kill(pid, 'SIGTERM')
168
- } catch {
169
- // Race: process already gone — treat as terminated.
170
- }
171
- await new Promise(resolve => setTimeout(resolve, 500))
172
- try {
173
- process.kill(pid, 'SIGKILL')
174
- } catch {
175
- // Already gone.
176
- }
177
- }
178
-
179
- /**
180
- * Terminate the DSH instance listening on one local port. The port serving
181
- * this wall may also be terminated (the user may want to stop the instance
182
- * they are viewing): the kill is deferred a beat so the HTTP response is
183
- * written before the process dies, then the listener's PIDs are force-killed.
184
- * @param port - the target port.
185
- * @param selfPort - this instance's own listening port.
186
- * @returns the stop result.
187
- */
188
- export async function stopPort(port: number, selfPort: number): Promise<StopRow> {
189
- try {
190
- const pids = await listeningPids(port)
191
- if (pids.length === 0) {
192
- return { port, ok: false, error: 'no listener on this port' }
193
- }
194
- const kill = () => Promise.all(pids.map(pid => killPid(pid).catch(() => {})))
195
- if (port === selfPort) {
196
- // Let the response flush before taking ourselves down.
197
- setTimeout(() => { void kill() }, 250)
198
- return { port, ok: true }
199
- }
200
- await kill()
201
- return { port, ok: true }
202
- } catch (error) {
203
- return { port, ok: false, error: error instanceof Error ? error.message : String(error) }
204
- }
205
- }
206
-
207
- /** How a new `dsh web` process is spawned. */
208
- interface Launcher {
209
- file: string
210
- args: string[]
211
- /** Resolve `file` through the shell (needed for the Windows .cmd shim). */
212
- shell: boolean
213
- }
214
-
215
- /**
216
- * Resolve how to launch a new DSH instance. Primary path: the current
217
- * process's own entry (`node <bin> web` under `process.argv[1]`), so the new
218
- * instance inherits the exact CLI/profile already running. Fallback: the
219
- * `dsh` command from PATH when the entry cannot be derived (unusual host
220
- * launcher, missing file).
221
- * @returns the launcher description.
222
- */
223
- function resolveLauncher(): Launcher {
224
- const first = process.argv[1]
225
- if (first !== undefined && existsSync(first)) {
226
- return { file: process.execPath, args: [first, 'web', '--port'], shell: false }
227
- }
228
- return { file: 'dsh', args: ['web', '--port'], shell: process.platform === 'win32' }
229
- }
230
-
231
- /**
232
- * Collect every distinct local TCP port that is listening, in ONE command
233
- * (not one `netstat`/`lsof` per candidate, which the old free-port scan ran
234
- * sequentially and could take many seconds on slow Windows boxes). Windows
235
- * parses `netstat`; POSIX parses `lsof` `(LISTEN)` lines.
236
- * @returns the set of busy ports.
237
- */
238
- async function listeningPorts(): Promise<Set<number>> {
239
- const set = new Set<number>()
240
- if (process.platform === 'win32') {
241
- const stdout = await execStdout('netstat', ['-ano', '-p', 'tcp'])
242
- for (const line of stdout.split(/\r?\n/)) {
243
- // TCP 127.0.0.1:3080 0.0.0.0:0 LISTENING 12345
244
- const m = /^\s*TCP\s+([0-9.]+|\*|\[::\]):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/.exec(line)
245
- if (m !== null) set.add(Number(m[2]))
246
- }
247
- return set
248
- }
249
- const stdout = await execStdout('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'])
250
- for (const line of stdout.split(/\r?\n/)) {
251
- // node 1234 user 13u IPv4 12345 0t0 TCP 127.0.0.1:3080 (LISTEN)
252
- const m = /:(\d+)\s+\(LISTEN\)\s*$/.exec(line.trim())
253
- if (m !== null) set.add(Number(m[1]))
254
- }
255
- return set
256
- }
257
-
258
- /**
259
- * Pick the first free port in [lo, hi] that is neither the serving port nor
260
- * already listening. The busy set is resolved once (a single command), then
261
- * scanned in memory.
262
- * @param lo - first port of the range.
263
- * @param hi - last port of the range.
264
- * @param selfPort - the port serving this wall (never chosen).
265
- * @returns a free port, or undefined when the range is exhausted.
266
- */
267
- async function pickFreePort(lo: number, hi: number, selfPort: number): Promise<number | undefined> {
268
- const busy = await listeningPorts()
269
- for (let port = lo; port <= hi; port++) {
270
- if (port === selfPort) continue
271
- if (busy.has(port)) continue
272
- return port
273
- }
274
- return undefined
275
- }
276
-
277
- /**
278
- * Spawn a new `dsh web` instance on a port and decide quickly whether it is
279
- * viable. Detached so it outlives this process. This is intentionally NOT a
280
- * full readiness gate: the wall polls liveness itself, so the create response
281
- * returns fast and a still-booting instance simply shows a pane that lights up
282
- * when the server finishes. A short bounded wait still catches immediate
283
- * spawn failures (bad bin, ENOENT) and very fast boots.
284
- *
285
- * On a genuine launch failure the child is killed so no orphan lingers; on a
286
- * slow-but-viable start the deadline returns ok:true and the child keeps
287
- * booting in the background (the pane already mounted, liveness confirms when
288
- * alive). The child's stderr is captured and quoted into every failure so a
289
- * crash or a bad bin surfaces a concrete reason instead of a bare timeout.
290
- * @param launcher - how to spawn the dsh CLI.
291
- * @param port - the port for the new instance.
292
- * @param timeoutMs - how long to wait before handing back ok:true.
293
- * @param pollMs - readiness probe interval while waiting.
294
- * @returns ok plus the port, or ok:false with a reason.
295
- */
296
- async function startInstance(
297
- launcher: Launcher,
298
- port: number,
299
- timeoutMs = 3000,
300
- pollMs = 300,
301
- ): Promise<{ ok: boolean; port: number; error?: string }> {
302
- const child = spawn(launcher.file, [...launcher.args, String(port)], {
303
- detached: true,
304
- stdio: ['ignore', 'ignore', 'pipe'],
305
- windowsHide: true,
306
- shell: launcher.shell,
307
- })
308
- child.unref()
309
- let stderr = ''
310
- // Held by property so flow analysis cannot conclude the closure assignment
311
- // never runs (a local assigned only inside a callback narrows to never at
312
- // the check).
313
- const spawnFailure: { error: Error | null } = { error: null }
314
- child.stderr?.on('data', chunk => {
315
- stderr += String(chunk)
316
- if (stderr.length > 2000) stderr = stderr.slice(-2000)
317
- })
318
- child.once('error', error => { spawnFailure.error = error })
319
- const detail = (): string => {
320
- const tail = stderr.trim().split(/\r?\n/).slice(-3).join(' | ')
321
- return tail === '' ? '' : ` (${tail})`
322
- }
323
- const deadline = Date.now() + timeoutMs
324
- for (;;) {
325
- if (spawnFailure.error !== null) {
326
- // Can't launch at all: reap the child so no orphan lingers.
327
- try { child.kill() } catch { /* already gone */ }
328
- return { ok: false, port, error: `new instance failed to start: ${spawnFailure.error.message}` }
329
- }
330
- if (child.exitCode !== null) {
331
- return { ok: false, port, error: `new instance exited early (code ${child.exitCode})${detail()}` }
332
- }
333
- const row = await probePort(port)
334
- if (row.alive) return { ok: true, port }
335
- if (Date.now() > deadline) {
336
- // Not ready yet but viable: hand back ok so the create response isn't
337
- // blocked on a slow boot — the pane mounts and the wall's own liveness
338
- // poll lights it up when the server finishes starting.
339
- return { ok: true, port }
340
- }
341
- await new Promise(resolve => setTimeout(resolve, pollMs))
342
- }
343
- }
344
-
345
- /**
346
- * Interface-name patterns that mark a *virtual* NIC (VM bridge, WSL,
347
- * Docker, Hyper-V, VPN adapters, Loopback Pseudo-Instance, etc.). These
348
- * interfaces are never reachable from a phone on the same LAN, so they are
349
- * dropped from the link list entirely.
350
- */
351
- const VIRTUAL_IFACE_PATTERNS: RegExp[] = [
352
- /vEthernet/i, // Hyper-V virtual switch
353
- /vmware/i, // VMware VMnet adapters
354
- /virtualbox/i, // VirtualBox host-only
355
- /^vbox/i,
356
- /wsl/i, // Windows Subsystem for Linux
357
- /docker/i, // Docker / DockerNAT
358
- /hyper-v/i,
359
- /tap-windows/i, // OpenVPN TAP
360
- /^tap/i,
361
- /^tun/i, // TUN VPN tunnels
362
- /ppp/i,
363
- /loopback/i, // Loopback Pseudo-Interface 1
364
- /apipa/i, // automatic private IP (169.254.*.*)
365
- /^utun/i, // macOS TUN
366
- /^awdl/i, // macOS Apple Wireless Direct Link
367
- /^llw/i, // macOS low-latency WLAN
368
- /^bridge/i,
369
- /bluetooth/i,
370
- ]
371
-
372
- /**
373
- * Interface-name patterns that mark a *physical* NIC (Wi-Fi / Ethernet).
374
- * Matches kept addresses are ordered before any unknown-but-surviving
375
- * address so the phone-first address is the machine's real NIC.
376
- */
377
- const PHYSICAL_IFACE_PATTERNS: RegExp[] = [
378
- /^(wi-?fi|wlan|wireless)/i,
379
- /^(eth(ernet)?|以太网|以太)/i,
380
- /^(en|wan)[0-9]/i, // macOS en0 / en1
381
- /^本地连接/i,
382
- /^e[0-9]+$/i, // bare ethernet (linux)
383
- /^w[0-9]+$/i, // bare wlan (linux)
384
- ]
385
-
386
- interface LanCandidate {
387
- address: string
388
- physical: boolean
389
- virtual: boolean
390
- }
391
-
392
- /**
393
- * The non-loopback IPv4 addresses of this machine (the LAN reachable URLs).
394
- * Virtual NICs (VM/WSL/Docker/VPN/loopback pseudo) are filtered out; the
395
- * remaining addresses are ordered with physical NICs (Wi-Fi/Ethernet) first
396
- * so the phone shows the actually-reachable LAN address at the top.
397
- * @returns the address list (possibly empty).
398
- */
399
- function lanAddresses(): string[] {
400
- const candidates: LanCandidate[] = []
401
- for (const [name, ifaces] of Object.entries(networkInterfaces())) {
402
- const virtual = VIRTUAL_IFACE_PATTERNS.some(re => re.test(name))
403
- const physical = !virtual && PHYSICAL_IFACE_PATTERNS.some(re => re.test(name))
404
- for (const iface of ifaces ?? []) {
405
- if (iface.family !== 'IPv4' || iface.internal) continue
406
- if (virtual) continue // drop virtual NICs entirely
407
- candidates.push({ address: iface.address, physical, virtual: false })
408
- }
409
- }
410
- candidates.sort((a, b) => Number(b.physical) - Number(a.physical))
411
- return candidates.map(c => c.address)
412
- }
413
-
414
- /**
415
- * Register the probe routes. Everything lives under `/multi/api` so the
416
- * plugin is purely additive: exact `ports` (auto-discovery) and `status`
417
- * (liveness of a specific port list).
418
- * @param ctx - plugin context carrying the webServer service.
419
- * @param config - validated {@link MultiWallConfig}.
420
- */
421
- export function apply(ctx: Context, config: MultiWallConfig = {}): void {
422
- const scanFrom = config.scanFrom ?? 3070
423
- const scanTo = config.scanTo ?? 3110
424
- const fixedPorts = config.ports ?? []
425
-
426
- // Inline gateway state: lazily started on first `/multi/api/link` call and
427
- // reused until the target port changes (or the instance restarts).
428
- let gateway: GatewayHandle | null = null
429
- let gatewayTargetPort = -1
430
-
431
- ctx.effect(() => ctx.webServer.register({
432
- kind: 'exact',
433
- path: '/multi/api/ports',
434
- handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
435
- if (req.method !== 'GET' && req.method !== 'HEAD') {
436
- res.writeHead(405)
437
- res.end()
438
- return
439
- }
440
- const url = new URL(req.url ?? '/', 'http://x')
441
- const qFromRaw = url.searchParams.get('from')
442
- const qToRaw = url.searchParams.get('to')
443
- const qFrom = qFromRaw !== null ? Number(qFromRaw) : NaN
444
- const qTo = qToRaw !== null ? Number(qToRaw) : NaN
445
- const lo = Number.isInteger(qFrom) ? qFrom : scanFrom
446
- const hi = Number.isInteger(qTo) ? qTo : scanTo
447
- const ports = fixedPorts.length > 0 ? [...fixedPorts] : []
448
- if (fixedPorts.length === 0) {
449
- for (let p = lo; p <= hi; p++) ports.push(p)
450
- }
451
- // The serving instance is a discoverable target too: the user may want
452
- // to watch (or stop) the very instance hosting the wall. Recursion is
453
- // prevented client-side by the ?multi-wall=embed pane flag, not by
454
- // hiding the self port.
455
- probePorts(ports).then(results => {
456
- json(res, { ports: results.filter(row => row.alive) })
457
- }).catch(() => json(res, { ports: [] }, 500))
458
- },
459
- }), 'multi-wall: /multi/api/ports')
460
-
461
- ctx.effect(() => ctx.webServer.register({
462
- kind: 'exact',
463
- path: '/multi/api/status',
464
- handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
465
- if (req.method !== 'GET' && req.method !== 'HEAD') {
466
- res.writeHead(405)
467
- res.end()
468
- return
469
- }
470
- const url = new URL(req.url ?? '/', 'http://x')
471
- const ports = (url.searchParams.get('ports') ?? '')
472
- .split(',')
473
- .map(Number)
474
- .filter(p => Number.isInteger(p) && p > 0)
475
- probePorts(ports).then(results => {
476
- json(res, { ports: results })
477
- }).catch(() => json(res, { ports: [] }, 500))
478
- },
479
- }), 'multi-wall: /multi/api/status')
480
-
481
- // Terminate the DSH instance on a specific port (closes that session).
482
- // GET /multi/api/stop?port=3080 or ?ports=3080,3081
483
- ctx.effect(() => ctx.webServer.register({
484
- kind: 'exact',
485
- path: '/multi/api/stop',
486
- handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
487
- if (req.method !== 'GET' && req.method !== 'POST') {
488
- res.writeHead(405)
489
- res.end()
490
- return
491
- }
492
- const url = new URL(req.url ?? '/', 'http://x')
493
- const raw = url.searchParams.get('ports') ?? url.searchParams.get('port') ?? ''
494
- const ports = raw.split(',').map(Number).filter(p => Number.isInteger(p) && p > 0)
495
- const selfPort = ctx.webServer.port
496
- Promise.all(ports.map(port => stopPort(port, selfPort))).then(results => {
497
- json(res, { ports: results })
498
- }).catch(() => json(res, { ports: [] }, 500))
499
- },
500
- }), 'multi-wall: /multi/api/stop')
501
-
502
- // Start a NEW DSH instance and return its port, so the wall can grow a
503
- // fresh window without leaving the page. Spawns `dsh web` on the first
504
- // free port of the scan range (never the serving port). The response
505
- // returns as soon as a port is allocated (a couple seconds max) instead of
506
- // blocking on the new instance's readiness — the wall's own liveness poll
507
- // confirms the server when it finishes booting, and a spawn failure is
508
- // surfaced immediately as ok:false with a concrete reason.
509
- // POST /multi/api/create (GET also accepted for convenience)
510
- ctx.effect(() => ctx.webServer.register({
511
- kind: 'exact',
512
- path: '/multi/api/create',
513
- handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
514
- if (req.method !== 'GET' && req.method !== 'POST') {
515
- res.writeHead(405)
516
- res.end()
517
- return
518
- }
519
- const launcher = resolveLauncher()
520
- const selfPort = ctx.webServer.port
521
- void pickFreePort(scanFrom, scanTo, selfPort).then(port => {
522
- if (port === undefined) {
523
- json(res, { ok: false, error: `no free port in ${scanFrom}–${scanTo}` }, 409)
524
- return
525
- }
526
- return startInstance(launcher, port).then(result => {
527
- json(res, result.ok ? { ok: true, port } : { ok: false, error: result.error }, result.ok ? 200 : 500)
528
- if (!result.ok) ctx.logger.warn(`multi-wall create failed: ${result.error}`)
529
- })
530
- }).catch((error: unknown) => {
531
- const message = error instanceof Error ? error.message : String(error)
532
- ctx.logger.warn(`multi-wall create error: ${message}`)
533
- json(res, { ok: false, error: message }, 500)
534
- })
535
- },
536
- }), 'multi-wall: /multi/api/create')
537
-
538
- // The phone-reachable URL for this instance. The official CLI forbids
539
- // `--host 0.0.0.0` (it would expose remote code execution), so a loopback
540
- // instance is reached from a phone through an auth-gated gateway. When
541
- // `publicUrl` is configured, that URL is reported verbatim. Otherwise this
542
- // route lazily starts the inline gateway (target 127.0.0.1:<selfPort>) and
543
- // answers with the LAN URLs plus the generated/fixed login token.
544
- // GET /multi/api/link
545
- ctx.effect(() => ctx.webServer.register({
546
- kind: 'exact',
547
- path: '/multi/api/link',
548
- handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
549
- if (req.method !== 'GET' && req.method !== 'HEAD') {
550
- res.writeHead(405)
551
- res.end()
552
- return
553
- }
554
- const port = ctx.webServer.port
555
- const host = ctx.webServer.host
556
- const publicUrl = (config.publicUrl ?? '').replace(/\/+$/, '')
557
- if (publicUrl !== '') {
558
- json(res, { port, host, lan: [`${publicUrl}/`], reachable: true })
559
- return
560
- }
561
-
562
- // Ensure the inline gateway targets THIS instance's port.
563
- const ensureGateway = (): Promise<GatewayHandle> => {
564
- if (gateway !== null && gatewayTargetPort === port) {
565
- return Promise.resolve(gateway)
566
- }
567
- // Target changed (or first start): close the stale gateway first.
568
- if (gateway !== null) {
569
- gateway.close()
570
- gateway = null
571
- }
572
- const token = config.gatewayToken && config.gatewayToken !== '' ? config.gatewayToken : randomBytes(6).toString('hex')
573
- const gatewayPort = config.gatewayPort && config.gatewayPort !== 0 ? config.gatewayPort : port + 5000
574
- gatewayTargetPort = port
575
- // Allow the gateway's `/gw/<port>` route only for ports a DSH instance
576
- // can actually live on: the scan range plus any fixed ports.
577
- const routed: number[] = []
578
- for (let p = scanFrom; p <= scanTo; p++) routed.push(p)
579
- for (const p of fixedPorts) if (!routed.includes(p)) routed.push(p)
580
- return startGateway({
581
- targetPort: port,
582
- port: gatewayPort,
583
- token,
584
- name: 'DSH',
585
- routedPorts: routed,
586
- log: (msg) => ctx.logger.info(`multi-wall gateway: ${msg}`),
587
- }).then(handle => {
588
- gateway = handle
589
- return handle
590
- })
591
- }
592
-
593
- ensureGateway().then(handle => {
594
- const urls = lanAddresses().map(ip => `http://${ip}:${handle.port}/`)
595
- json(res, {
596
- port,
597
- host,
598
- lan: urls,
599
- gatewayPort: handle.port,
600
- token: handle.token,
601
- reachable: urls.length > 0,
602
- hint: urls.length === 0
603
- ? 'no LAN address detected; connect this machine to a network first'
604
- : undefined,
605
- })
606
- }).catch((error: unknown) => {
607
- ctx.logger.warn(`multi-wall gateway start failed: ${error instanceof Error ? error.message : String(error)}`)
608
- json(res, {
609
- port,
610
- host,
611
- lan: [],
612
- reachable: false,
613
- hint: error instanceof Error ? error.message : String(error),
614
- }, 500)
615
- })
616
- },
617
- }), 'multi-wall: /multi/api/link')
618
- }
1
+ /**
2
+ * Multi-window wall plugin, node half: registers the `/multi/api/*` routes
3
+ * on the webserver. The browser half fetches these same-origin to discover
4
+ * which local ports are live DSH instances, to poll liveness, and to
5
+ * terminate a chosen instance (`/multi/api/stop`). The wall itself is pure
6
+ * UI — this half answers a few small JSON requests.
7
+ * @module dsh-multi-chat
8
+ */
9
+
10
+ import { execFile, spawn } from 'node:child_process'
11
+ import { randomBytes } from 'node:crypto'
12
+ import { existsSync, readFileSync, readdirSync, readlinkSync } from 'node:fs'
13
+ import { networkInterfaces } from 'node:os'
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ import z from '@deepseek-ai/schemastery'
16
+ import type {} from '@deepseek-ai/dsh-host-webserver'
17
+ import { startGateway, type GatewayHandle } from './gateway'
18
+
19
+ /** Stable Cordis plugin name. */
20
+ export const name = 'dsh-multi-chat'
21
+
22
+ /** Services required before the probe routes can be registered. */
23
+ export const inject = ['webServer']
24
+
25
+ /** Plugin config: the wall's auto-discovery scan range. */
26
+ export interface MultiWallConfig {
27
+ /** First port of the auto-discovery range. */
28
+ scanFrom?: number
29
+ /** Last port of the auto-discovery range. */
30
+ scanTo?: number
31
+ /** Optional fixed port list; when set, discovery ignores the scan range. */
32
+ ports?: number[]
33
+ /**
34
+ * External base URL reported by `/multi/api/link` (e.g. the authenticated
35
+ * gateway in front of this loopback instance). When set, the link route
36
+ * answers `{ lan: [publicUrl + '/'], reachable: true }`.
37
+ */
38
+ publicUrl?: string
39
+ /**
40
+ * Gateway listen port for the inline phone-access gateway. `0` (default)
41
+ * means `targetPort + 5000`.
42
+ */
43
+ gatewayPort?: number
44
+ /**
45
+ * Optional fixed login token for the inline gateway. Empty (default) means
46
+ * a random token is generated per gateway start (returned by /multi/api/link).
47
+ */
48
+ gatewayToken?: string
49
+ }
50
+
51
+ /** Schema-validated config (the Loader resolves defaults for absent keys). */
52
+ export const Config = z.object({
53
+ scanFrom: z.natural().default(3070),
54
+ scanTo: z.natural().default(3110),
55
+ ports: z.array(z.natural()).default([]),
56
+ publicUrl: z.string().default(''),
57
+ gatewayPort: z.number().default(0),
58
+ gatewayToken: z.string().default(''),
59
+ })
60
+
61
+ /** MIME for JSON probe answers. */
62
+ const JSON_TYPE = 'application/json; charset=utf-8'
63
+
64
+ /** One probe result row. */
65
+ interface ProbeRow {
66
+ port: number
67
+ alive: boolean
68
+ status: number
69
+ }
70
+
71
+ /** GET one local URL with a short timeout; resolve {status, body} or reject. */
72
+ async function request(url: string, timeoutMs = 600): Promise<{ status: number; body: string }> {
73
+ const controller = new AbortController()
74
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
75
+ try {
76
+ const res = await fetch(url, { signal: controller.signal })
77
+ return { status: res.status, body: await res.text() }
78
+ } finally {
79
+ clearTimeout(timer)
80
+ }
81
+ }
82
+
83
+ /** Is this local port a live DSH instance (index.html carries __DSH_BOOT__)? */
84
+ async function probePort(port: number): Promise<ProbeRow> {
85
+ try {
86
+ const { status, body } = await request(`http://127.0.0.1:${port}/`)
87
+ return { port, alive: status === 200 && body.includes('__DSH_BOOT__'), status }
88
+ } catch {
89
+ return { port, alive: false, status: 0 }
90
+ }
91
+ }
92
+
93
+ /** Concurrent probe of many ports (bounded chunking). */
94
+ async function probePorts(ports: number[]): Promise<ProbeRow[]> {
95
+ const CHUNK = 16
96
+ const out: ProbeRow[] = []
97
+ for (let i = 0; i < ports.length; i += CHUNK) {
98
+ out.push(...(await Promise.all(ports.slice(i, i + CHUNK).map(port => probePort(port)))))
99
+ }
100
+ return out
101
+ }
102
+
103
+ /** Send a small JSON response. */
104
+ function json(res: import('node:http').ServerResponse, value: unknown, status = 200): void {
105
+ res.writeHead(status, { 'content-type': JSON_TYPE, 'cache-control': 'no-store' })
106
+ res.end(JSON.stringify(value))
107
+ }
108
+
109
+ /** One stop result row from /multi/api/stop. */
110
+ export interface StopRow {
111
+ port: number
112
+ ok: boolean
113
+ /** Human-readable failure reason (absent on success). */
114
+ error?: string
115
+ }
116
+
117
+ /**
118
+ * Run a command and resolve its stdout text. Rejects on non-zero exit.
119
+ * @param file - the executable path.
120
+ * @param args - CLI arguments.
121
+ * @returns the trimmed stdout.
122
+ */
123
+ function execStdout(file: string, args: string[]): Promise<string> {
124
+ return new Promise((resolve, reject) => {
125
+ execFile(file, args, { timeout: 5000 }, (error, stdout) => {
126
+ if (error !== null) {
127
+ reject(error)
128
+ return
129
+ }
130
+ resolve(stdout)
131
+ })
132
+ })
133
+ }
134
+
135
+ /**
136
+ * Whether an exec failure means the binary itself is absent (ENOENT) rather
137
+ * than the command running and reporting a non-zero exit.
138
+ * @param error - the rejection from {@link execStdout}.
139
+ * @returns true when the executable could not be spawned at all.
140
+ */
141
+ function isMissingBinary(error: unknown): boolean {
142
+ return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
143
+ }
144
+
145
+ /**
146
+ * Read the local TCP sockets in LISTEN state from the kernel's `/proc` tables.
147
+ * This is the Linux fallback for hosts without `lsof`, which is most
148
+ * container images — including the ones `dsh web` is commonly run in. Only
149
+ * this network namespace's sockets are visible, which is exactly the view the
150
+ * serving instance and the instances it spawns live in.
151
+ * @param port - restrict the scan to one port when given.
152
+ * @returns listening port -> owning socket inode ('' when the row has none).
153
+ */
154
+ function procListenSockets(port?: number): Map<number, string> {
155
+ const found = new Map<number, string>()
156
+ for (const file of ['/proc/net/tcp', '/proc/net/tcp6']) {
157
+ let text: string
158
+ try {
159
+ text = readFileSync(file, 'utf8')
160
+ } catch {
161
+ continue // An absent IPv6 table is normal; either table alone suffices.
162
+ }
163
+ for (const line of text.split('\n').slice(1)) {
164
+ // sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
165
+ // 0: 0100007F:0CEA 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345 1
166
+ const f = line.trim().split(/\s+/)
167
+ if (f.length < 10 || f[3] !== '0A') continue
168
+ const local = f[1] ?? ''
169
+ const listening = Number.parseInt(local.slice(local.indexOf(':') + 1), 16)
170
+ if (!Number.isInteger(listening) || listening <= 0) continue
171
+ if (port !== undefined && listening !== port) continue
172
+ found.set(listening, f[9] ?? '')
173
+ }
174
+ }
175
+ return found
176
+ }
177
+
178
+ /**
179
+ * Resolve which PIDs own the given socket inodes by scanning `/proc/<pid>/fd`
180
+ * links. Only processes this user is allowed to inspect are visible — the
181
+ * same permission bound `lsof` has.
182
+ * @param inodes - the socket inodes to look for.
183
+ * @returns the owning PIDs (possibly empty).
184
+ */
185
+ function pidsForInodes(inodes: Iterable<string>): number[] {
186
+ const wanted = new Set(
187
+ [...inodes].filter(inode => inode !== '').map(inode => `socket:[${inode}]`),
188
+ )
189
+ if (wanted.size === 0) return []
190
+ let entries: string[]
191
+ try {
192
+ entries = readdirSync('/proc')
193
+ } catch {
194
+ return []
195
+ }
196
+ const pids = new Set<number>()
197
+ for (const entry of entries) {
198
+ const pid = Number(entry)
199
+ if (!Number.isInteger(pid) || pid <= 0) continue
200
+ let fds: string[]
201
+ try {
202
+ fds = readdirSync(`/proc/${entry}/fd`)
203
+ } catch {
204
+ continue // Exited, or not ours to inspect.
205
+ }
206
+ for (const fd of fds) {
207
+ try {
208
+ if (wanted.has(readlinkSync(`/proc/${entry}/fd/${fd}`))) {
209
+ pids.add(pid)
210
+ break
211
+ }
212
+ } catch {
213
+ // Descriptor closed between readdir and readlink.
214
+ }
215
+ }
216
+ }
217
+ return [...pids]
218
+ }
219
+
220
+ /**
221
+ * Resolve the PIDs listening on a local TCP port. Windows uses `netstat`;
222
+ * POSIX prefers `lsof` and falls back to the kernel's `/proc` tables on Linux
223
+ * hosts that do not ship it.
224
+ * @param port - the listening port.
225
+ * @returns the listener PIDs (possibly empty).
226
+ */
227
+ async function listeningPids(port: number): Promise<number[]> {
228
+ if (process.platform === 'win32') {
229
+ const stdout = await execStdout('netstat', ['-ano', '-p', 'tcp'])
230
+ const pids = new Set<number>()
231
+ for (const line of stdout.split(/\r?\n/)) {
232
+ // TCP 127.0.0.1:3080 0.0.0.0:0 LISTENING 12345
233
+ const m = /^\s*TCP\s+([0-9.]+|\*|\[::\]):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/.exec(line)
234
+ if (m !== null && Number(m[2]) === port) pids.add(Number(m[3]))
235
+ }
236
+ return [...pids]
237
+ }
238
+ try {
239
+ const stdout = await execStdout('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'])
240
+ return stdout.split(/\s+/).map(Number).filter(pid => Number.isInteger(pid) && pid > 0)
241
+ } catch (error) {
242
+ if (process.platform !== 'linux' || !isMissingBinary(error)) throw error
243
+ return pidsForInodes(procListenSockets(port).values())
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Terminate one PID. Windows uses `taskkill /F` (force); POSIX sends SIGTERM
249
+ * then SIGKILL after a grace period.
250
+ * @param pid - the process id to terminate.
251
+ */
252
+ async function killPid(pid: number): Promise<void> {
253
+ if (process.platform === 'win32') {
254
+ await execStdout('taskkill', ['/PID', String(pid), '/F', '/T'])
255
+ return
256
+ }
257
+ try {
258
+ process.kill(pid, 'SIGTERM')
259
+ } catch {
260
+ // Race: process already gone — treat as terminated.
261
+ }
262
+ await new Promise(resolve => setTimeout(resolve, 500))
263
+ try {
264
+ process.kill(pid, 'SIGKILL')
265
+ } catch {
266
+ // Already gone.
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Terminate the DSH instance listening on one local port. The port serving
272
+ * this wall may also be terminated (the user may want to stop the instance
273
+ * they are viewing): the kill is deferred a beat so the HTTP response is
274
+ * written before the process dies, then the listener's PIDs are force-killed.
275
+ * @param port - the target port.
276
+ * @param selfPort - this instance's own listening port.
277
+ * @returns the stop result.
278
+ */
279
+ export async function stopPort(port: number, selfPort: number): Promise<StopRow> {
280
+ try {
281
+ const pids = await listeningPids(port)
282
+ if (pids.length === 0) {
283
+ return { port, ok: false, error: 'no listener on this port' }
284
+ }
285
+ const kill = () => Promise.all(pids.map(pid => killPid(pid).catch(() => {})))
286
+ if (port === selfPort) {
287
+ // Let the response flush before taking ourselves down.
288
+ setTimeout(() => { void kill() }, 250)
289
+ return { port, ok: true }
290
+ }
291
+ await kill()
292
+ return { port, ok: true }
293
+ } catch (error) {
294
+ return { port, ok: false, error: error instanceof Error ? error.message : String(error) }
295
+ }
296
+ }
297
+
298
+ /** How a new `dsh web` process is spawned. */
299
+ interface Launcher {
300
+ file: string
301
+ args: string[]
302
+ /** Resolve `file` through the shell (needed for the Windows .cmd shim). */
303
+ shell: boolean
304
+ }
305
+
306
+ /**
307
+ * Resolve how to launch a new DSH instance. Primary path: the current
308
+ * process's own entry (`node <bin> web` under `process.argv[1]`), so the new
309
+ * instance inherits the exact CLI/profile already running. Fallback: the
310
+ * `dsh` command from PATH when the entry cannot be derived (unusual host
311
+ * launcher, missing file).
312
+ * @returns the launcher description.
313
+ */
314
+ function resolveLauncher(): Launcher {
315
+ const first = process.argv[1]
316
+ if (first !== undefined && existsSync(first)) {
317
+ return { file: process.execPath, args: [first, 'web', '--port'], shell: false }
318
+ }
319
+ return { file: 'dsh', args: ['web', '--port'], shell: process.platform === 'win32' }
320
+ }
321
+
322
+ /**
323
+ * Collect every distinct local TCP port that is listening, in ONE command
324
+ * (not one `netstat`/`lsof` per candidate, which the old free-port scan ran
325
+ * sequentially and could take many seconds on slow Windows boxes). Windows
326
+ * parses `netstat`; POSIX parses `lsof` `(LISTEN)` lines, falling back to
327
+ * `/proc/net/tcp*` on Linux hosts without `lsof`.
328
+ * @returns the set of busy ports.
329
+ */
330
+ async function listeningPorts(): Promise<Set<number>> {
331
+ const set = new Set<number>()
332
+ if (process.platform === 'win32') {
333
+ const stdout = await execStdout('netstat', ['-ano', '-p', 'tcp'])
334
+ for (const line of stdout.split(/\r?\n/)) {
335
+ // TCP 127.0.0.1:3080 0.0.0.0:0 LISTENING 12345
336
+ const m = /^\s*TCP\s+([0-9.]+|\*|\[::\]):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/.exec(line)
337
+ if (m !== null) set.add(Number(m[2]))
338
+ }
339
+ return set
340
+ }
341
+ try {
342
+ const stdout = await execStdout('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'])
343
+ for (const line of stdout.split(/\r?\n/)) {
344
+ // node 1234 user 13u IPv4 12345 0t0 TCP 127.0.0.1:3080 (LISTEN)
345
+ const m = /:(\d+)\s+\(LISTEN\)\s*$/.exec(line.trim())
346
+ if (m !== null) set.add(Number(m[1]))
347
+ }
348
+ return set
349
+ } catch (error) {
350
+ if (process.platform !== 'linux' || !isMissingBinary(error)) throw error
351
+ return new Set(procListenSockets().keys())
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Pick the first free port in [lo, hi] that is neither the serving port nor
357
+ * already listening. The busy set is resolved once (a single command), then
358
+ * scanned in memory.
359
+ * @param lo - first port of the range.
360
+ * @param hi - last port of the range.
361
+ * @param selfPort - the port serving this wall (never chosen).
362
+ * @returns a free port, or undefined when the range is exhausted.
363
+ */
364
+ async function pickFreePort(lo: number, hi: number, selfPort: number): Promise<number | undefined> {
365
+ const busy = await listeningPorts()
366
+ for (let port = lo; port <= hi; port++) {
367
+ if (port === selfPort) continue
368
+ if (busy.has(port)) continue
369
+ return port
370
+ }
371
+ return undefined
372
+ }
373
+
374
+ /**
375
+ * Spawn a new `dsh web` instance on a port and decide quickly whether it is
376
+ * viable. Detached so it outlives this process. This is intentionally NOT a
377
+ * full readiness gate: the wall polls liveness itself, so the create response
378
+ * returns fast and a still-booting instance simply shows a pane that lights up
379
+ * when the server finishes. A short bounded wait still catches immediate
380
+ * spawn failures (bad bin, ENOENT) and very fast boots.
381
+ *
382
+ * On a genuine launch failure the child is killed so no orphan lingers; on a
383
+ * slow-but-viable start the deadline returns ok:true and the child keeps
384
+ * booting in the background (the pane already mounted, liveness confirms when
385
+ * alive). The child's stderr is captured and quoted into every failure so a
386
+ * crash or a bad bin surfaces a concrete reason instead of a bare timeout.
387
+ * @param launcher - how to spawn the dsh CLI.
388
+ * @param port - the port for the new instance.
389
+ * @param timeoutMs - how long to wait before handing back ok:true.
390
+ * @param pollMs - readiness probe interval while waiting.
391
+ * @returns ok plus the port, or ok:false with a reason.
392
+ */
393
+ async function startInstance(
394
+ launcher: Launcher,
395
+ port: number,
396
+ timeoutMs = 3000,
397
+ pollMs = 300,
398
+ ): Promise<{ ok: boolean; port: number; error?: string }> {
399
+ const child = spawn(launcher.file, [...launcher.args, String(port)], {
400
+ detached: true,
401
+ stdio: ['ignore', 'ignore', 'pipe'],
402
+ windowsHide: true,
403
+ shell: launcher.shell,
404
+ })
405
+ child.unref()
406
+ let stderr = ''
407
+ // Held by property so flow analysis cannot conclude the closure assignment
408
+ // never runs (a local assigned only inside a callback narrows to never at
409
+ // the check).
410
+ const spawnFailure: { error: Error | null } = { error: null }
411
+ child.stderr?.on('data', chunk => {
412
+ stderr += String(chunk)
413
+ if (stderr.length > 2000) stderr = stderr.slice(-2000)
414
+ })
415
+ child.once('error', error => { spawnFailure.error = error })
416
+ const detail = (): string => {
417
+ const tail = stderr.trim().split(/\r?\n/).slice(-3).join(' | ')
418
+ return tail === '' ? '' : ` (${tail})`
419
+ }
420
+ const deadline = Date.now() + timeoutMs
421
+ for (;;) {
422
+ if (spawnFailure.error !== null) {
423
+ // Can't launch at all: reap the child so no orphan lingers.
424
+ try { child.kill() } catch { /* already gone */ }
425
+ return { ok: false, port, error: `new instance failed to start: ${spawnFailure.error.message}` }
426
+ }
427
+ if (child.exitCode !== null) {
428
+ return { ok: false, port, error: `new instance exited early (code ${child.exitCode})${detail()}` }
429
+ }
430
+ const row = await probePort(port)
431
+ if (row.alive) return { ok: true, port }
432
+ if (Date.now() > deadline) {
433
+ // Not ready yet but viable: hand back ok so the create response isn't
434
+ // blocked on a slow boot — the pane mounts and the wall's own liveness
435
+ // poll lights it up when the server finishes starting.
436
+ return { ok: true, port }
437
+ }
438
+ await new Promise(resolve => setTimeout(resolve, pollMs))
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Interface-name patterns that mark a *virtual* NIC (VM bridge, WSL,
444
+ * Docker, Hyper-V, VPN adapters, Loopback Pseudo-Instance, etc.). These
445
+ * interfaces are never reachable from a phone on the same LAN, so they are
446
+ * dropped from the link list entirely.
447
+ */
448
+ const VIRTUAL_IFACE_PATTERNS: RegExp[] = [
449
+ /vEthernet/i, // Hyper-V virtual switch
450
+ /vmware/i, // VMware VMnet adapters
451
+ /virtualbox/i, // VirtualBox host-only
452
+ /^vbox/i,
453
+ /wsl/i, // Windows Subsystem for Linux
454
+ /docker/i, // Docker / DockerNAT
455
+ /hyper-v/i,
456
+ /tap-windows/i, // OpenVPN TAP
457
+ /^tap/i,
458
+ /^tun/i, // TUN VPN tunnels
459
+ /ppp/i,
460
+ /loopback/i, // Loopback Pseudo-Interface 1
461
+ /apipa/i, // automatic private IP (169.254.*.*)
462
+ /^utun/i, // macOS TUN
463
+ /^awdl/i, // macOS Apple Wireless Direct Link
464
+ /^llw/i, // macOS low-latency WLAN
465
+ /^bridge/i,
466
+ /bluetooth/i,
467
+ ]
468
+
469
+ /**
470
+ * Interface-name patterns that mark a *physical* NIC (Wi-Fi / Ethernet).
471
+ * Matches kept addresses are ordered before any unknown-but-surviving
472
+ * address so the phone-first address is the machine's real NIC.
473
+ */
474
+ const PHYSICAL_IFACE_PATTERNS: RegExp[] = [
475
+ /^(wi-?fi|wlan|wireless)/i,
476
+ /^(eth(ernet)?|以太网|以太)/i,
477
+ /^(en|wan)[0-9]/i, // macOS en0 / en1
478
+ /^本地连接/i,
479
+ /^e[0-9]+$/i, // bare ethernet (linux)
480
+ /^w[0-9]+$/i, // bare wlan (linux)
481
+ ]
482
+
483
+ interface LanCandidate {
484
+ address: string
485
+ physical: boolean
486
+ virtual: boolean
487
+ }
488
+
489
+ /**
490
+ * The non-loopback IPv4 addresses of this machine (the LAN reachable URLs).
491
+ * Virtual NICs (VM/WSL/Docker/VPN/loopback pseudo) are filtered out; the
492
+ * remaining addresses are ordered with physical NICs (Wi-Fi/Ethernet) first
493
+ * so the phone shows the actually-reachable LAN address at the top.
494
+ * @returns the address list (possibly empty).
495
+ */
496
+ function lanAddresses(): string[] {
497
+ const candidates: LanCandidate[] = []
498
+ for (const [name, ifaces] of Object.entries(networkInterfaces())) {
499
+ const virtual = VIRTUAL_IFACE_PATTERNS.some(re => re.test(name))
500
+ const physical = !virtual && PHYSICAL_IFACE_PATTERNS.some(re => re.test(name))
501
+ for (const iface of ifaces ?? []) {
502
+ if (iface.family !== 'IPv4' || iface.internal) continue
503
+ if (virtual) continue // drop virtual NICs entirely
504
+ candidates.push({ address: iface.address, physical, virtual: false })
505
+ }
506
+ }
507
+ candidates.sort((a, b) => Number(b.physical) - Number(a.physical))
508
+ return candidates.map(c => c.address)
509
+ }
510
+
511
+ /**
512
+ * Register the probe routes. Everything lives under `/multi/api` so the
513
+ * plugin is purely additive: exact `ports` (auto-discovery) and `status`
514
+ * (liveness of a specific port list).
515
+ * @param ctx - plugin context carrying the webServer service.
516
+ * @param config - validated {@link MultiWallConfig}.
517
+ */
518
+ export function apply(ctx: Context, config: MultiWallConfig = {}): void {
519
+ const scanFrom = config.scanFrom ?? 3070
520
+ const scanTo = config.scanTo ?? 3110
521
+ const fixedPorts = config.ports ?? []
522
+
523
+ // Inline gateway state: lazily started on first `/multi/api/link` call and
524
+ // reused until the target port changes (or the instance restarts).
525
+ let gateway: GatewayHandle | null = null
526
+ let gatewayTargetPort = -1
527
+
528
+ ctx.effect(() => ctx.webServer.register({
529
+ kind: 'exact',
530
+ path: '/multi/api/ports',
531
+ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
532
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
533
+ res.writeHead(405)
534
+ res.end()
535
+ return
536
+ }
537
+ const url = new URL(req.url ?? '/', 'http://x')
538
+ const qFromRaw = url.searchParams.get('from')
539
+ const qToRaw = url.searchParams.get('to')
540
+ const qFrom = qFromRaw !== null ? Number(qFromRaw) : NaN
541
+ const qTo = qToRaw !== null ? Number(qToRaw) : NaN
542
+ const lo = Number.isInteger(qFrom) ? qFrom : scanFrom
543
+ const hi = Number.isInteger(qTo) ? qTo : scanTo
544
+ const ports = fixedPorts.length > 0 ? [...fixedPorts] : []
545
+ if (fixedPorts.length === 0) {
546
+ for (let p = lo; p <= hi; p++) ports.push(p)
547
+ }
548
+ // The serving instance is a discoverable target too: the user may want
549
+ // to watch (or stop) the very instance hosting the wall. Recursion is
550
+ // prevented client-side by the ?multi-wall=embed pane flag, not by
551
+ // hiding the self port.
552
+ probePorts(ports).then(results => {
553
+ json(res, { ports: results.filter(row => row.alive) })
554
+ }).catch(() => json(res, { ports: [] }, 500))
555
+ },
556
+ }), 'multi-wall: /multi/api/ports')
557
+
558
+ ctx.effect(() => ctx.webServer.register({
559
+ kind: 'exact',
560
+ path: '/multi/api/status',
561
+ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
562
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
563
+ res.writeHead(405)
564
+ res.end()
565
+ return
566
+ }
567
+ const url = new URL(req.url ?? '/', 'http://x')
568
+ const ports = (url.searchParams.get('ports') ?? '')
569
+ .split(',')
570
+ .map(Number)
571
+ .filter(p => Number.isInteger(p) && p > 0)
572
+ probePorts(ports).then(results => {
573
+ json(res, { ports: results })
574
+ }).catch(() => json(res, { ports: [] }, 500))
575
+ },
576
+ }), 'multi-wall: /multi/api/status')
577
+
578
+ // Terminate the DSH instance on a specific port (closes that session).
579
+ // GET /multi/api/stop?port=3080 or ?ports=3080,3081
580
+ ctx.effect(() => ctx.webServer.register({
581
+ kind: 'exact',
582
+ path: '/multi/api/stop',
583
+ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
584
+ if (req.method !== 'GET' && req.method !== 'POST') {
585
+ res.writeHead(405)
586
+ res.end()
587
+ return
588
+ }
589
+ const url = new URL(req.url ?? '/', 'http://x')
590
+ const raw = url.searchParams.get('ports') ?? url.searchParams.get('port') ?? ''
591
+ const ports = raw.split(',').map(Number).filter(p => Number.isInteger(p) && p > 0)
592
+ const selfPort = ctx.webServer.port
593
+ Promise.all(ports.map(port => stopPort(port, selfPort))).then(results => {
594
+ json(res, { ports: results })
595
+ }).catch(() => json(res, { ports: [] }, 500))
596
+ },
597
+ }), 'multi-wall: /multi/api/stop')
598
+
599
+ // Start a NEW DSH instance and return its port, so the wall can grow a
600
+ // fresh window without leaving the page. Spawns `dsh web` on the first
601
+ // free port of the scan range (never the serving port). The response
602
+ // returns as soon as a port is allocated (a couple seconds max) instead of
603
+ // blocking on the new instance's readiness — the wall's own liveness poll
604
+ // confirms the server when it finishes booting, and a spawn failure is
605
+ // surfaced immediately as ok:false with a concrete reason.
606
+ // POST /multi/api/create (GET also accepted for convenience)
607
+ ctx.effect(() => ctx.webServer.register({
608
+ kind: 'exact',
609
+ path: '/multi/api/create',
610
+ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
611
+ if (req.method !== 'GET' && req.method !== 'POST') {
612
+ res.writeHead(405)
613
+ res.end()
614
+ return
615
+ }
616
+ const launcher = resolveLauncher()
617
+ const selfPort = ctx.webServer.port
618
+ void pickFreePort(scanFrom, scanTo, selfPort).then(port => {
619
+ if (port === undefined) {
620
+ json(res, { ok: false, error: `no free port in ${scanFrom}–${scanTo}` }, 409)
621
+ return
622
+ }
623
+ return startInstance(launcher, port).then(result => {
624
+ json(res, result.ok ? { ok: true, port } : { ok: false, error: result.error }, result.ok ? 200 : 500)
625
+ if (!result.ok) ctx.logger.warn(`multi-wall create failed: ${result.error}`)
626
+ })
627
+ }).catch((error: unknown) => {
628
+ const message = error instanceof Error ? error.message : String(error)
629
+ ctx.logger.warn(`multi-wall create error: ${message}`)
630
+ json(res, { ok: false, error: message }, 500)
631
+ })
632
+ },
633
+ }), 'multi-wall: /multi/api/create')
634
+
635
+ // The phone-reachable URL for this instance. The official CLI forbids
636
+ // `--host 0.0.0.0` (it would expose remote code execution), so a loopback
637
+ // instance is reached from a phone through an auth-gated gateway. When
638
+ // `publicUrl` is configured, that URL is reported verbatim. Otherwise this
639
+ // route lazily starts the inline gateway (target 127.0.0.1:<selfPort>) and
640
+ // answers with the LAN URLs plus the generated/fixed login token.
641
+ // GET /multi/api/link
642
+ ctx.effect(() => ctx.webServer.register({
643
+ kind: 'exact',
644
+ path: '/multi/api/link',
645
+ handler: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
646
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
647
+ res.writeHead(405)
648
+ res.end()
649
+ return
650
+ }
651
+ const port = ctx.webServer.port
652
+ const host = ctx.webServer.host
653
+ const publicUrl = (config.publicUrl ?? '').replace(/\/+$/, '')
654
+ if (publicUrl !== '') {
655
+ json(res, { port, host, lan: [`${publicUrl}/`], reachable: true })
656
+ return
657
+ }
658
+
659
+ // Ensure the inline gateway targets THIS instance's port.
660
+ const ensureGateway = (): Promise<GatewayHandle> => {
661
+ if (gateway !== null && gatewayTargetPort === port) {
662
+ return Promise.resolve(gateway)
663
+ }
664
+ // Target changed (or first start): close the stale gateway first.
665
+ if (gateway !== null) {
666
+ gateway.close()
667
+ gateway = null
668
+ }
669
+ const token = config.gatewayToken && config.gatewayToken !== '' ? config.gatewayToken : randomBytes(6).toString('hex')
670
+ const gatewayPort = config.gatewayPort && config.gatewayPort !== 0 ? config.gatewayPort : port + 5000
671
+ gatewayTargetPort = port
672
+ // Allow the gateway's `/gw/<port>` route only for ports a DSH instance
673
+ // can actually live on: the scan range plus any fixed ports.
674
+ const routed: number[] = []
675
+ for (let p = scanFrom; p <= scanTo; p++) routed.push(p)
676
+ for (const p of fixedPorts) if (!routed.includes(p)) routed.push(p)
677
+ return startGateway({
678
+ targetPort: port,
679
+ port: gatewayPort,
680
+ token,
681
+ name: 'DSH',
682
+ routedPorts: routed,
683
+ log: (msg) => ctx.logger.info(`multi-wall gateway: ${msg}`),
684
+ }).then(handle => {
685
+ gateway = handle
686
+ return handle
687
+ })
688
+ }
689
+
690
+ ensureGateway().then(handle => {
691
+ const urls = lanAddresses().map(ip => `http://${ip}:${handle.port}/`)
692
+ json(res, {
693
+ port,
694
+ host,
695
+ lan: urls,
696
+ gatewayPort: handle.port,
697
+ token: handle.token,
698
+ reachable: urls.length > 0,
699
+ hint: urls.length === 0
700
+ ? 'no LAN address detected; connect this machine to a network first'
701
+ : undefined,
702
+ })
703
+ }).catch((error: unknown) => {
704
+ ctx.logger.warn(`multi-wall gateway start failed: ${error instanceof Error ? error.message : String(error)}`)
705
+ json(res, {
706
+ port,
707
+ host,
708
+ lan: [],
709
+ reachable: false,
710
+ hint: error instanceof Error ? error.message : String(error),
711
+ }, 500)
712
+ })
713
+ },
714
+ }), 'multi-wall: /multi/api/link')
715
+ }