dsh-multi-chat 0.6.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +20 -21
  2. package/README.zh.md +20 -21
  3. package/bin/dsh-multi-chat.mjs +5 -9
  4. package/lib/client.js +778 -0
  5. package/lib/client.js.map +1 -0
  6. package/{plugin/dsh-client-ui-multi-wall/lib → lib}/index.js +57 -69
  7. package/lib/tsconfig.tsbuildinfo +1 -0
  8. package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/WallToggle.d.ts +1 -1
  9. package/lib/types/client/WallToggle.d.ts.map +1 -0
  10. package/lib/types/client/WallToggle.js +36 -0
  11. package/lib/types/client/WallToggle.js.map +1 -0
  12. package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/WallView.d.ts +1 -1
  13. package/lib/types/client/WallView.d.ts.map +1 -0
  14. package/lib/types/client/WallView.js +218 -0
  15. package/lib/types/client/WallView.js.map +1 -0
  16. package/lib/types/client/index.d.ts.map +1 -0
  17. package/lib/types/client/index.js +59 -0
  18. package/lib/types/client/index.js.map +1 -0
  19. package/lib/types/client/locales.d.ts.map +1 -0
  20. package/lib/types/client/locales.js +96 -0
  21. package/lib/types/client/locales.js.map +1 -0
  22. package/lib/types/client/store.d.ts.map +1 -0
  23. package/lib/types/client/store.js +27 -0
  24. package/lib/types/client/store.js.map +1 -0
  25. package/lib/types/client/wall-injected.d.ts.map +1 -0
  26. package/lib/types/client/wall-injected.js +76 -0
  27. package/lib/types/client/wall-injected.js.map +1 -0
  28. package/lib/types/gateway.d.ts +57 -0
  29. package/lib/types/gateway.d.ts.map +1 -0
  30. package/lib/types/gateway.js +566 -0
  31. package/lib/types/gateway.js.map +1 -0
  32. package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/index.d.ts +1 -16
  33. package/lib/types/index.d.ts.map +1 -0
  34. package/lib/types/index.js +550 -0
  35. package/lib/types/index.js.map +1 -0
  36. package/lib/types/invariant.d.ts.map +1 -0
  37. package/lib/types/invariant.js +26 -0
  38. package/lib/types/invariant.js.map +1 -0
  39. package/package.json +41 -17
  40. package/scripts/install-plugin.ps1 +6 -9
  41. package/src/client/WallToggle.module.css +26 -0
  42. package/src/client/WallToggle.tsx +52 -0
  43. package/src/client/WallView.module.css +190 -0
  44. package/src/client/WallView.tsx +406 -0
  45. package/src/client/index.ts +103 -0
  46. package/src/client/locales.ts +100 -0
  47. package/src/client/store.ts +40 -0
  48. package/src/client/wall-injected.ts +123 -0
  49. package/src/css-modules.d.ts +5 -0
  50. package/src/gateway.ts +627 -0
  51. package/src/index.ts +618 -0
  52. package/src/invariant.ts +34 -0
  53. package/plugin/dsh-client-ui-multi-wall/README.md +0 -26
  54. package/plugin/dsh-client-ui-multi-wall/README.zh.md +0 -26
  55. package/plugin/dsh-client-ui-multi-wall/cordis.patch.yml +0 -7
  56. package/plugin/dsh-client-ui-multi-wall/lib/client.js +0 -781
  57. package/plugin/dsh-client-ui-multi-wall/lib/invariant.js +0 -27
  58. package/plugin/dsh-client-ui-multi-wall/package.json +0 -88
  59. /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/index.d.ts +0 -0
  60. /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/locales.d.ts +0 -0
  61. /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/store.d.ts +0 -0
  62. /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/wall-injected.d.ts +0 -0
  63. /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/invariant.d.ts +0 -0
package/src/index.ts ADDED
@@ -0,0 +1,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 @deepseek-ai/dsh-client-ui-multi-wall
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 = 'client-ui-multi-wall'
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
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Package-owned invariant companion for
3
+ * `@deepseek-ai/dsh-client-ui-multi-wall`.
4
+ * @module @deepseek-ai/dsh-client-ui-multi-wall/invariant
5
+ */
6
+
7
+ /* jscpd:ignore-start */
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
10
+
11
+ const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-multi-wall'
12
+
13
+ /** Cordis companion plugin name. */
14
+ export const name = 'client-ui-multi-wall-invariant'
15
+ /** Service required before the companion can reserve package ownership. */
16
+ export const inject = ['invariants']
17
+
18
+ /**
19
+ * No runtime invariant: the wall contributes the conversation view-ring entry
20
+ * (the wall surface) and the sidebar footer shortcut, whose disposal is
21
+ * proven by the HMR-safety spec — the plugin owns one store handle used by
22
+ * the view entry, emits no cordis events, and holds no cross-plugin mutable
23
+ * state.
24
+ */
25
+ const install: InvariantInstaller = () => {}
26
+
27
+ /**
28
+ * Register this package's invariant companion.
29
+ * @param ctx - Cordis context carrying the invariant service.
30
+ * @returns the installed registration's disposer after setup succeeds.
31
+ */
32
+ export const apply = (ctx: Context): Promise<() => void> =>
33
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
34
+ /* jscpd:ignore-end */
@@ -1,26 +0,0 @@
1
- # @deepseek-ai/dsh-client-ui-multi-wall
2
-
3
- English | [中文](README.zh.md)
4
-
5
- Multi-window wall plugin, browser half + node half: a grid of every running DSH instance, one pane per `127.0.0.1:<port>`, rendered inside the official web GUI as an additive `conversation.view` ring entry (order 20). The view swaps the chat panel in place for a wall of iframes, each loading the original DSH Web UI with a `?multi-wall=embed` flag that suppresses the wall UI inside the pane — recursion is stopped at the source. The sidebar foot gains a `sidebar.footer.action` shortcut (order 10) that clicks the header's view-ring tab for this plugin, so the switch goes through the official view-ring state machine rather than reaching into the chat store.
6
-
7
- The wall's business state is a single store (`dsh.multi-wall`): the discovered port list and the grid column count, persisted across view switches and reloads. Discovery, liveness, create, and stop all flow through the node half's read-only JSON routes — `/multi/api/ports` (auto-discovery, excluding nothing so the serving instance is also watchable), `/multi/api/status` (liveness of a specific port list), `/multi/api/stop` (terminate a chosen instance), `/multi/api/create` (start a fresh instance, surfacing the child's stderr on failure), and `/multi/api/link` (phone access).
8
-
9
- Phone/remote access: the official CLI forbids `--host 0.0.0.0` (it would expose remote code execution), so `/multi/api/link` lazily starts an **inline authenticated gateway** (raw `node:net` reverse proxy with an HMAC-signed session-cookie login, targets `127.0.0.1:<self-port>`, rewrites Host/Origin so the official `/api` browser-trust fence sees a local request, and passes WebSocket upgrades through). The route returns the LAN URLs plus the login token. The returned addresses drop virtual NICs (VMware/VirtualBox/WSL/Docker/Hyper-V/VPN — unreachable from a phone) and put physical NICs (Wi-Fi/Ethernet) first; the gateway falls back to an OS-assigned port when its intended port hits a Windows excluded range or is already bound.
10
-
11
- The `/client` exports the plugin body (`apply`/`inject`), the `WallView`/`WallToggle` components, the wall store factory, and the injected probe-face types.
12
-
13
- ## Model Experience
14
-
15
- None. The plugin adds no prompt content, no session event, and no model-visible input; the wall, its store, and every `/multi/api/*` route are UI/discovery surfaces only. No token or KV-cache effect.
16
-
17
- #### KV Cache effect
18
-
19
- None. Nothing the plugin owns reaches the history tail or the model context.
20
-
21
- ## Known Limitations and Deferred Work
22
-
23
- - **Loopback-only panes** — the wall embeds `127.0.0.1:<port>` and probes the loopback; an instance bound to a non-loopback host needs external configuration.
24
- - **Probe is a marker check** — liveness only checks that the served index carries `__DSH_BOOT__`; a non-DSH service squatting the same port reads as "not found".
25
- - **Session-scoped view** — the wall is a `conversation.view` ring entry, so it renders only with an active session.
26
- - **Inline gateway is plain HTTP** — on a trusted LAN the token over plain HTTP is acceptable; across the internet prefer `publicUrl` (an external TLS gateway) or a VPN.