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.
- package/README.md +20 -21
- package/README.zh.md +20 -21
- package/bin/dsh-multi-chat.mjs +5 -9
- package/lib/client.js +778 -0
- package/lib/client.js.map +1 -0
- package/{plugin/dsh-client-ui-multi-wall/lib → lib}/index.js +57 -69
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/WallToggle.d.ts +1 -1
- package/lib/types/client/WallToggle.d.ts.map +1 -0
- package/lib/types/client/WallToggle.js +36 -0
- package/lib/types/client/WallToggle.js.map +1 -0
- package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/WallView.d.ts +1 -1
- package/lib/types/client/WallView.d.ts.map +1 -0
- package/lib/types/client/WallView.js +218 -0
- package/lib/types/client/WallView.js.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +59 -0
- package/lib/types/client/index.js.map +1 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/locales.js +96 -0
- package/lib/types/client/locales.js.map +1 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/client/store.js +27 -0
- package/lib/types/client/store.js.map +1 -0
- package/lib/types/client/wall-injected.d.ts.map +1 -0
- package/lib/types/client/wall-injected.js +76 -0
- package/lib/types/client/wall-injected.js.map +1 -0
- package/lib/types/gateway.d.ts +57 -0
- package/lib/types/gateway.d.ts.map +1 -0
- package/lib/types/gateway.js +566 -0
- package/lib/types/gateway.js.map +1 -0
- package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/index.d.ts +1 -16
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +550 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +26 -0
- package/lib/types/invariant.js.map +1 -0
- package/package.json +41 -17
- package/scripts/install-plugin.ps1 +6 -9
- package/src/client/WallToggle.module.css +26 -0
- package/src/client/WallToggle.tsx +52 -0
- package/src/client/WallView.module.css +190 -0
- package/src/client/WallView.tsx +406 -0
- package/src/client/index.ts +103 -0
- package/src/client/locales.ts +100 -0
- package/src/client/store.ts +40 -0
- package/src/client/wall-injected.ts +123 -0
- package/src/css-modules.d.ts +5 -0
- package/src/gateway.ts +627 -0
- package/src/index.ts +618 -0
- package/src/invariant.ts +34 -0
- package/plugin/dsh-client-ui-multi-wall/README.md +0 -26
- package/plugin/dsh-client-ui-multi-wall/README.zh.md +0 -26
- package/plugin/dsh-client-ui-multi-wall/cordis.patch.yml +0 -7
- package/plugin/dsh-client-ui-multi-wall/lib/client.js +0 -781
- package/plugin/dsh-client-ui-multi-wall/lib/invariant.js +0 -27
- package/plugin/dsh-client-ui-multi-wall/package.json +0 -88
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/index.d.ts +0 -0
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/locales.d.ts +0 -0
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/store.d.ts +0 -0
- /package/{plugin/dsh-client-ui-multi-wall/lib → lib}/types/client/wall-injected.d.ts +0 -0
- /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
|
+
}
|
package/src/invariant.ts
ADDED
|
@@ -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.
|