@lijian-ui/dsh-term 0.1.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/cordis.patch.yml +8 -0
  4. package/lib/client.js +7824 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.js +434 -0
  7. package/lib/tsconfig.client.tsbuildinfo +1 -0
  8. package/lib/tsconfig.host.tsbuildinfo +1 -0
  9. package/lib/types/client/index.d.ts +26 -0
  10. package/lib/types/client/index.d.ts.map +1 -0
  11. package/lib/types/client/term/TerminalPanel.d.ts +24 -0
  12. package/lib/types/client/term/TerminalPanel.d.ts.map +1 -0
  13. package/lib/types/client/term/api.d.ts +34 -0
  14. package/lib/types/client/term/api.d.ts.map +1 -0
  15. package/lib/types/client/term/xterm-styles.d.ts +9 -0
  16. package/lib/types/client/term/xterm-styles.d.ts.map +1 -0
  17. package/lib/types/core/types.d.ts +76 -0
  18. package/lib/types/core/types.d.ts.map +1 -0
  19. package/lib/types/host/loopback.d.ts +25 -0
  20. package/lib/types/host/loopback.d.ts.map +1 -0
  21. package/lib/types/host/pty-service.d.ts +45 -0
  22. package/lib/types/host/pty-service.d.ts.map +1 -0
  23. package/lib/types/host/routes.d.ts +17 -0
  24. package/lib/types/host/routes.d.ts.map +1 -0
  25. package/lib/types/index.d.ts +19 -0
  26. package/lib/types/index.d.ts.map +1 -0
  27. package/lib/types/mount-once.d.ts +25 -0
  28. package/lib/types/mount-once.d.ts.map +1 -0
  29. package/package.json +79 -0
  30. package/src/client/css-modules.d.ts +4 -0
  31. package/src/client/index.ts +223 -0
  32. package/src/client/term/TerminalPanel.tsx +259 -0
  33. package/src/client/term/api.ts +66 -0
  34. package/src/client/term/term.module.css +153 -0
  35. package/src/client/term/xterm-styles.ts +182 -0
  36. package/src/core/types.ts +73 -0
  37. package/src/host/loopback.ts +63 -0
  38. package/src/host/pty-service.ts +131 -0
  39. package/src/host/routes.ts +198 -0
  40. package/src/index.ts +37 -0
  41. package/src/mount-once.ts +48 -0
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Inlined xterm.js 5.3.0 core styles (MIT) — injected as a <style> tag at
3
+ * runtime because the client build compiles only *.module.css and the
4
+ * shell's css-modules pipeline rejects a raw :global block with xterm's
5
+ * class names.
6
+ * @module dsh-term/client/term/xterm-styles
7
+ */
8
+
9
+ export const XTERM_CSS: string = `
10
+ .xterm {
11
+ cursor: text;
12
+ position: relative;
13
+ user-select: none;
14
+ -ms-user-select: none;
15
+ -webkit-user-select: none;
16
+ }
17
+
18
+ .xterm.focus,
19
+ .xterm:focus {
20
+ outline: none;
21
+ }
22
+
23
+ .xterm .xterm-helpers {
24
+ position: absolute;
25
+ top: 0;
26
+ /**
27
+ * The z-index of the helpers must be higher than the canvases in order for
28
+ * IMEs to appear on top.
29
+ */
30
+ z-index: 5;
31
+ }
32
+
33
+ .xterm .xterm-helper-textarea {
34
+ padding: 0;
35
+ border: 0;
36
+ margin: 0;
37
+ /* Move textarea out of the screen to the far left, so that the cursor is not visible */
38
+ position: absolute;
39
+ opacity: 0;
40
+ left: -9999em;
41
+ top: 0;
42
+ width: 0;
43
+ height: 0;
44
+ z-index: -5;
45
+ /** Prevent wrapping so the IME appears against the textarea at the correct position */
46
+ white-space: nowrap;
47
+ overflow: hidden;
48
+ resize: none;
49
+ }
50
+
51
+ .xterm .composition-view {
52
+ /* TODO: Composition position got messed up somewhere */
53
+ background: #000;
54
+ color: #FFF;
55
+ display: none;
56
+ position: absolute;
57
+ white-space: nowrap;
58
+ z-index: 1;
59
+ }
60
+
61
+ .xterm .composition-view.active {
62
+ display: block;
63
+ }
64
+
65
+ .xterm .xterm-viewport {
66
+ /* On OS X this is required in order for the scroll bar to appear fully opaque */
67
+ background-color: #000;
68
+ overflow-y: scroll;
69
+ cursor: default;
70
+ position: absolute;
71
+ right: 0;
72
+ left: 0;
73
+ top: 0;
74
+ bottom: 0;
75
+ }
76
+
77
+ .xterm .xterm-screen {
78
+ position: relative;
79
+ }
80
+
81
+ .xterm .xterm-screen canvas {
82
+ position: absolute;
83
+ left: 0;
84
+ top: 0;
85
+ }
86
+
87
+ .xterm .xterm-scroll-area {
88
+ visibility: hidden;
89
+ }
90
+
91
+ .xterm-char-measure-element {
92
+ display: inline-block;
93
+ visibility: hidden;
94
+ position: absolute;
95
+ top: 0;
96
+ left: -9999em;
97
+ line-height: normal;
98
+ }
99
+
100
+ .xterm.enable-mouse-events {
101
+ /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
102
+ cursor: default;
103
+ }
104
+
105
+ .xterm.xterm-cursor-pointer,
106
+ .xterm .xterm-cursor-pointer {
107
+ cursor: pointer;
108
+ }
109
+
110
+ .xterm.column-select.focus {
111
+ /* Column selection mode */
112
+ cursor: crosshair;
113
+ }
114
+
115
+ .xterm .xterm-accessibility,
116
+ .xterm .xterm-message {
117
+ position: absolute;
118
+ left: 0;
119
+ top: 0;
120
+ bottom: 0;
121
+ right: 0;
122
+ z-index: 10;
123
+ color: transparent;
124
+ pointer-events: none;
125
+ }
126
+
127
+ .xterm .live-region {
128
+ position: absolute;
129
+ left: -9999px;
130
+ width: 1px;
131
+ height: 1px;
132
+ overflow: hidden;
133
+ }
134
+
135
+ .xterm-dim {
136
+ /* Dim should not apply to background, so the opacity of the foreground color is applied
137
+ * explicitly in the generated class and reset to 1 here */
138
+ opacity: 1 !important;
139
+ }
140
+
141
+ .xterm-underline-1 { text-decoration: underline; }
142
+ .xterm-underline-2 { text-decoration: double underline; }
143
+ .xterm-underline-3 { text-decoration: wavy underline; }
144
+ .xterm-underline-4 { text-decoration: dotted underline; }
145
+ .xterm-underline-5 { text-decoration: dashed underline; }
146
+
147
+ .xterm-overline {
148
+ text-decoration: overline;
149
+ }
150
+
151
+ .xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
152
+ .xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
153
+ .xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
154
+ .xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
155
+ .xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
156
+
157
+ .xterm-strikethrough {
158
+ text-decoration: line-through;
159
+ }
160
+
161
+ .xterm-screen .xterm-decoration-container .xterm-decoration {
162
+ z-index: 6;
163
+ position: absolute;
164
+ }
165
+
166
+ .xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
167
+ z-index: 7;
168
+ }
169
+
170
+ .xterm-decoration-overview-ruler {
171
+ z-index: 8;
172
+ position: absolute;
173
+ top: 0;
174
+ right: 0;
175
+ pointer-events: none;
176
+ }
177
+
178
+ .xterm-decoration-top {
179
+ z-index: 2;
180
+ position: relative;
181
+ }
182
+ `
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Wire types shared between the host half and the browser half of dsh-term.
3
+ * Pure data — no runtime imports (keeps the client bundle purity gate happy).
4
+ * @module dsh-term/core/types
5
+ */
6
+
7
+ /** One live PTY session as the browser knows it. */
8
+ export interface TermSessionInfo {
9
+ /** Stable session id (the wire handle). */
10
+ readonly id: string
11
+ /** User-facing tab title (defaults to the shell name). */
12
+ readonly title: string
13
+ /** Session cwd (absolute). */
14
+ readonly cwd: string
15
+ /** PTY size at last resize. */
16
+ cols: number
17
+ rows: number
18
+ /** Whether the shell process is still alive. */
19
+ readonly alive: boolean
20
+ /** POSIX exit code when the session ended (null while alive). */
21
+ readonly exitCode: number | null
22
+ }
23
+
24
+ /** Request: open a new PTY session. */
25
+ export interface TermSpawnRequest {
26
+ /** Optional session display name. */
27
+ name?: string
28
+ /** Working directory (defaults to the session's workspace cwd). */
29
+ cwd?: string
30
+ /** Shell executable (defaults to the platform shell). */
31
+ shell?: string
32
+ /** Shell arguments (defaults to an interactive login-less profile). */
33
+ args?: string[]
34
+ /** Initial terminal size. */
35
+ cols?: number
36
+ rows?: number
37
+ }
38
+
39
+ /** Request: deliver terminal input. */
40
+ export interface TermWriteRequest {
41
+ readonly id: string
42
+ /** Raw bytes to write into the PTY (UTF-8 string; xterm emits UTF-8). */
43
+ readonly data: string
44
+ }
45
+
46
+ /** Request: resize one session. */
47
+ export interface TermResizeRequest {
48
+ readonly id: string
49
+ readonly cols: number
50
+ readonly rows: number
51
+ }
52
+
53
+ /** Request: signal one session. */
54
+ export interface TermSignalRequest {
55
+ readonly id: string
56
+ /** PTY signal name (e.g. `SIGHUP`, `SIGINT`, `SIGTERM`, `SIGKILL`). */
57
+ readonly signal: 'SIGHUP' | 'SIGINT' | 'SIGTERM' | 'SIGKILL'
58
+ }
59
+
60
+ /** The full session listing snapshot. */
61
+ export interface TermListResponse {
62
+ readonly sessions: readonly TermSessionInfo[]
63
+ }
64
+
65
+ /** One output chunk pushed over the SSE change stream. */
66
+ export type TermEvent =
67
+ | { readonly kind: 'output'; readonly id: string; readonly data: string }
68
+ | { readonly kind: 'exit'; readonly id: string; readonly exitCode: number }
69
+ | { readonly kind: 'start'; readonly session: TermSessionInfo }
70
+ | { readonly kind: 'closed'; readonly id: string }
71
+
72
+ /** The workspace-gated session id (host-minted; the wire carries only this). */
73
+ export type TermSessionId = string
@@ -0,0 +1,63 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Loopback trust fence shared by the host route families: socket address,
4
+ * Host header, and browser same-origin markers. Packages receive this file as
5
+ * a generated copy via scripts/sync-shared.mjs; edit the shared source and
6
+ * re-run the sync instead of editing a copy.
7
+ *
8
+ * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
9
+ * remote-web-ui gate), localhost hostnames, plus the browser same-origin
10
+ * markers (sec-fetch-site and Origin) for the request-level fence.
11
+ * @module dsh-web-ui-shared/host/loopback
12
+ */
13
+
14
+ import type { IncomingMessage } from 'node:http'
15
+
16
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
17
+ export function isIPv4Loopback(v4: string): boolean {
18
+ const parts = v4.split('.')
19
+ return parts.length === 4
20
+ && parts[0] === '127'
21
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
+ }
23
+
24
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
25
+ export function isLoopbackAddress(address: string | undefined): boolean {
26
+ if (address === undefined) return false
27
+ const normalized = address.toLowerCase()
28
+ if (normalized === '::1') return true
29
+ if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
30
+ return isIPv4Loopback(normalized)
31
+ }
32
+
33
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
34
+ export function isLoopbackHostname(hostname: string): boolean {
35
+ if (hostname === 'localhost' || hostname === '[::1]') return true
36
+ return isIPv4Loopback(hostname)
37
+ }
38
+
39
+ /**
40
+ * Request-level trust fence: a loopback socket address AND a loopback Host
41
+ * header, plus browser same-origin markers. The socket address is
42
+ * authoritative; X-Forwarded-For is never trusted.
43
+ */
44
+ export function isLoopbackRequest(request: IncomingMessage): boolean {
45
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false
46
+ const host = request.headers.host
47
+ if (typeof host !== 'string') return false
48
+ let hostUrl: URL
49
+ try {
50
+ hostUrl = new URL('http://' + host)
51
+ } catch {
52
+ return false
53
+ }
54
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
55
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
56
+ const origin = request.headers.origin
57
+ if (origin === undefined) return true
58
+ try {
59
+ return new URL(origin).host === hostUrl.host
60
+ } catch {
61
+ return false
62
+ }
63
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * PTY session service for dsh-term: a framework-free registry over node-pty.
3
+ *
4
+ * A "pure user terminal" (A-version) needs no dsh agent ownership — every
5
+ * session is a plain node-pty child process keyed by a host-minted id, with
6
+ * byte streams bridged to the browser over the /dsh-term/* HTTP layer. This
7
+ * deliberately does NOT use the official @deepseek-ai/dsh-terminal service:
8
+ * that registry requires an exact `Agent` owner (model-facing semantics) and
9
+ * its resolution path from a user-initiated web route is unverified; node-pty
10
+ * is already present in the desktop tree (ABI-matched, verified loadable) and
11
+ * gives full control over multi-tab local shells.
12
+ * @module dsh-term/host/pty-service
13
+ */
14
+
15
+ import { randomUUID } from 'node:crypto'
16
+ import * as nodePty from 'node-pty'
17
+ import type { TermSessionInfo, TermSpawnRequest } from '../core/types.ts'
18
+
19
+ /** Live session plus its pty handle (service-private). */
20
+ interface LiveSession {
21
+ readonly info: TermSessionInfo
22
+ readonly pty: nodePty.IPty
23
+ }
24
+
25
+ /** Default interactive shell for the platform. */
26
+ export function defaultShell(): string {
27
+ if (process.platform === 'win32') return 'powershell.exe'
28
+ return process.env.SHELL ?? '/bin/bash'
29
+ }
30
+
31
+ /** Default args for an interactive login-less shell. */
32
+ export function defaultArgs(shell: string): string[] {
33
+ if (process.platform === 'win32') return []
34
+ const base = shell.endsWith('bash') ? ['--noprofile', '--norc', '-i'] : ['-i']
35
+ return base
36
+ }
37
+
38
+ /**
39
+ * The PTY registry. Every mutation goes through this class so the route
40
+ * layer stays a thin HTTP shape (the file-manager pattern). Output/exit
41
+ * callbacks are assignable so the SSE layer can bind them after construction.
42
+ */
43
+ export class PtyService {
44
+ private readonly sessions = new Map<string, LiveSession>()
45
+
46
+ /** Fired with raw PTY output chunks (UTF-8). Bound by the route layer. */
47
+ onOutput: (sessionId: string, data: string) => void = () => {}
48
+ /** Fired once when a session exits. Bound by the route layer. */
49
+ onExit: (sessionId: string, exitCode: number) => void = () => {}
50
+
51
+ /** Open one session; returns the wire info immediately (output streams async). */
52
+ spawn(req: TermSpawnRequest): TermSessionInfo {
53
+ const id = randomUUID()
54
+ const shell = req.shell ?? defaultShell()
55
+ const args = req.args ?? defaultArgs(shell)
56
+ const cols = req.cols ?? 80
57
+ const rows = req.rows ?? 24
58
+ const cwd = req.cwd ?? process.cwd()
59
+ const pty = nodePty.spawn(shell, args, {
60
+ name: 'xterm-256color',
61
+ cols,
62
+ rows,
63
+ cwd,
64
+ })
65
+ const info: TermSessionInfo = {
66
+ id,
67
+ title: req.name ?? shell,
68
+ cwd,
69
+ cols,
70
+ rows,
71
+ alive: true,
72
+ exitCode: null,
73
+ }
74
+ this.sessions.set(id, { info, pty })
75
+ pty.onData((data) => { this.onOutput(id, data) })
76
+ pty.onExit(({ exitCode }) => {
77
+ this.sessions.delete(id)
78
+ this.onExit(id, exitCode)
79
+ })
80
+ return info
81
+ }
82
+
83
+ /** Write raw bytes (UTF-8) into a session. Returns false when unknown. */
84
+ write(id: string, data: string): boolean {
85
+ const live = this.sessions.get(id)
86
+ if (live === undefined) return false
87
+ try { live.pty.write(data) } catch { return false }
88
+ return true
89
+ }
90
+
91
+ /** Resize a session. Returns false when unknown. */
92
+ resize(id: string, cols: number, rows: number): boolean {
93
+ const live = this.sessions.get(id)
94
+ if (live === undefined) return false
95
+ try {
96
+ live.pty.resize(Math.max(2, cols), Math.max(2, rows))
97
+ live.info.cols = cols
98
+ live.info.rows = rows
99
+ } catch { return false }
100
+ return true
101
+ }
102
+
103
+ /** Deliver a signal (SIGINT/SIGHUP/SIGTERM/SIGKILL). Returns false when unknown. */
104
+ signal(id: string, signal: string): boolean {
105
+ const live = this.sessions.get(id)
106
+ if (live === undefined) return false
107
+ try { live.pty.kill(signal as Parameters<typeof live.pty.kill>[0]) } catch { return false }
108
+ return true
109
+ }
110
+
111
+ /** Close a session forcefully (SIGHUP semantics via kill). Returns false when unknown. */
112
+ close(id: string): boolean {
113
+ const live = this.sessions.get(id)
114
+ if (live === undefined) return false
115
+ try { live.pty.kill() } catch { /* already dead */ }
116
+ return true
117
+ }
118
+
119
+ /** The full session listing snapshot. */
120
+ list(): readonly TermSessionInfo[] {
121
+ return [...this.sessions.values()].map(({ info }) => ({ ...info }))
122
+ }
123
+
124
+ /** Close every session (route teardown). */
125
+ dispose(): void {
126
+ for (const live of this.sessions.values()) {
127
+ try { live.pty.kill() } catch { /* ignore */ }
128
+ }
129
+ this.sessions.clear()
130
+ }
131
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * /dsh-term/* route layer: JSON envelope (ok/error) for the PTY operations
3
+ * and one SSE stream (output/exit/start events) per client. Loopback-fenced
4
+ * like every other host route family — a terminal is arbitrary command
5
+ * execution, so only same-origin browser clients may reach it.
6
+ * @module dsh-term/host/routes
7
+ */
8
+
9
+ import type { IncomingMessage, ServerResponse } from 'node:http'
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import type {} from '@deepseek-ai/dsh-host-webserver'
12
+ import type { TermEvent, TermSpawnRequest } from '../core/types.ts'
13
+ import type { PtyService } from './pty-service.ts'
14
+ import { isLoopbackRequest } from './loopback.ts'
15
+
16
+ /** JSON envelope mirrors the file-manager panel shape. */
17
+ type Envelope<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
18
+
19
+ const OK = (value: unknown): Envelope<unknown> => ({ ok: true, value })
20
+ const FAIL = (message: string, code = 'internal'): Envelope<never> => ({ ok: false, error: { code, message } })
21
+ const MALFORMED = FAIL('malformed request')
22
+
23
+ /** One SSE subscriber. */
24
+ interface Subscriber {
25
+ readonly res: ServerResponse
26
+ }
27
+
28
+ /** Read a small JSON request body (bounded to 64 KiB). */
29
+ function readBody(req: IncomingMessage): Promise<unknown> {
30
+ return new Promise((resolve, reject) => {
31
+ const chunks: Buffer[] = []
32
+ let size = 0
33
+ req.on('data', (chunk: Buffer) => {
34
+ size += chunk.length
35
+ if (size > 64 * 1024) {
36
+ reject(new Error('request body too large'))
37
+ req.destroy()
38
+ return
39
+ }
40
+ chunks.push(chunk)
41
+ })
42
+ req.on('end', () => {
43
+ try {
44
+ resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf-8')))
45
+ } catch {
46
+ reject(new Error('invalid JSON'))
47
+ }
48
+ })
49
+ req.on('error', reject)
50
+ })
51
+ }
52
+
53
+ function json(res: ServerResponse, envelope: Envelope<unknown>, status = 200): void {
54
+ const body = JSON.stringify(envelope)
55
+ res.writeHead(status, {
56
+ 'content-type': 'application/json; charset=utf-8',
57
+ 'cache-control': 'no-store',
58
+ })
59
+ res.end(body)
60
+ }
61
+
62
+ /**
63
+ * Register the /dsh-term routes.
64
+ * @param ctx - context carrying the webServer service.
65
+ * @param pty - the session registry.
66
+ * @returns route disposers.
67
+ */
68
+ export function registerTermRoutes(ctx: Context, pty: PtyService): () => void {
69
+ const subscribers = new Set<Subscriber>()
70
+ const push = (event: TermEvent): void => {
71
+ for (const subscriber of subscribers) {
72
+ subscriber.res.write(`event: term\ndata: ${JSON.stringify(event)}\n\n`)
73
+ }
74
+ }
75
+
76
+ pty.onOutput = (id, data) => push({ kind: 'output', id, data })
77
+ pty.onExit = (id, exitCode) => push({ kind: 'exit', id, exitCode })
78
+
79
+ const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
80
+ if (!isLoopbackRequest(req)) {
81
+ json(res, FAIL('loopback-only', 'forbidden'), 403)
82
+ return
83
+ }
84
+ const url = new URL(req.url ?? '/', 'http://dsh-term.local')
85
+ try {
86
+ if (req.method === 'GET' && url.pathname === '/dsh-term/list') {
87
+ json(res, OK({ sessions: pty.list() }))
88
+ return
89
+ }
90
+ if (req.method !== 'POST') {
91
+ json(res, MALFORMED, 405)
92
+ return
93
+ }
94
+ let payload: unknown
95
+ try {
96
+ payload = await readBody(req)
97
+ } catch {
98
+ json(res, MALFORMED, 400)
99
+ return
100
+ }
101
+ switch (url.pathname) {
102
+ case '/dsh-term/spawn': {
103
+ const request = payload as Partial<TermSpawnRequest>
104
+ if (typeof request !== 'object' || request === null) {
105
+ json(res, MALFORMED, 400)
106
+ return
107
+ }
108
+ const session = pty.spawn({
109
+ name: typeof request.name === 'string' ? request.name : undefined,
110
+ cwd: typeof request.cwd === 'string' ? request.cwd : undefined,
111
+ shell: typeof request.shell === 'string' ? request.shell : undefined,
112
+ args: Array.isArray(request.args) ? request.args.filter((a): a is string => typeof a === 'string') : undefined,
113
+ cols: typeof request.cols === 'number' ? request.cols : undefined,
114
+ rows: typeof request.rows === 'number' ? request.rows : undefined,
115
+ })
116
+ push({ kind: 'start', session })
117
+ json(res, OK(session))
118
+ return
119
+ }
120
+ case '/dsh-term/write': {
121
+ const body = payload as { id?: unknown; data?: unknown }
122
+ if (typeof body?.id !== 'string' || typeof body?.data !== 'string') {
123
+ json(res, MALFORMED, 400)
124
+ return
125
+ }
126
+ json(res, OK({ ok: pty.write(body.id, body.data) }))
127
+ return
128
+ }
129
+ case '/dsh-term/resize': {
130
+ const body = payload as { id?: unknown; cols?: unknown; rows?: unknown }
131
+ if (typeof body?.id !== 'string' || typeof body?.cols !== 'number' || typeof body?.rows !== 'number') {
132
+ json(res, MALFORMED, 400)
133
+ return
134
+ }
135
+ json(res, OK({ ok: pty.resize(body.id, body.cols, body.rows) }))
136
+ return
137
+ }
138
+ case '/dsh-term/signal': {
139
+ const body = payload as { id?: unknown; signal?: unknown }
140
+ if (typeof body?.id !== 'string' || typeof body?.signal !== 'string') {
141
+ json(res, MALFORMED, 400)
142
+ return
143
+ }
144
+ json(res, OK({ ok: pty.signal(body.id, body.signal) }))
145
+ return
146
+ }
147
+ case '/dsh-term/close': {
148
+ const body = payload as { id?: unknown }
149
+ if (typeof body?.id !== 'string') {
150
+ json(res, MALFORMED, 400)
151
+ return
152
+ }
153
+ json(res, OK({ ok: pty.close(body.id) }))
154
+ return
155
+ }
156
+ default:
157
+ json(res, MALFORMED, 404)
158
+ }
159
+ } catch (error: unknown) {
160
+ ctx.logger.warn(`dsh-term: route failed: ${String(error)}`)
161
+ json(res, FAIL('internal error'))
162
+ }
163
+ }
164
+
165
+ const sse = (req: IncomingMessage, res: ServerResponse): void => {
166
+ if (!isLoopbackRequest(req)) {
167
+ res.writeHead(403).end('loopback-only')
168
+ return
169
+ }
170
+ res.writeHead(200, {
171
+ 'content-type': 'text/event-stream; charset=utf-8',
172
+ 'cache-control': 'no-store',
173
+ 'connection': 'keep-alive',
174
+ 'x-accel-buffering': 'no',
175
+ })
176
+ res.write(': connected\n\n')
177
+ const subscriber: Subscriber = { res }
178
+ subscribers.add(subscriber)
179
+ const heartbeat = setInterval(() => {
180
+ if (subscriber.res.writableEnded) return
181
+ subscriber.res.write(': ping\n\n')
182
+ }, 15_000)
183
+ req.on('close', () => {
184
+ clearInterval(heartbeat)
185
+ subscribers.delete(subscriber)
186
+ })
187
+ }
188
+
189
+ const disposers = [
190
+ ctx.webServer.register({ kind: 'prefix', path: '/dsh-term', handler }),
191
+ ctx.webServer.register({ kind: 'exact', path: '/dsh-term/events', handler: sse }),
192
+ ]
193
+ return () => {
194
+ for (const dispose of disposers) dispose()
195
+ for (const subscriber of subscribers) subscriber.res.end()
196
+ subscribers.clear()
197
+ }
198
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * dsh-term host half: mounts the PTY session service and the /dsh-term/*
3
+ * routes on the shared webserver. The browser half (src/client) renders the
4
+ * panel UI against these routes — no dsh source changes.
5
+ * @module dsh-term
6
+ */
7
+
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import type {} from '@deepseek-ai/dsh-host-webserver'
10
+ import { mountOnce } from './mount-once.ts'
11
+ import { PtyService } from './host/pty-service.ts'
12
+ import { registerTermRoutes } from './host/routes.ts'
13
+
14
+ /** Required services: the route registry. */
15
+ export const inject = ['webServer']
16
+
17
+ /** Model-facing announcement: plugin presence. */
18
+ export const DSH_TERM_GUIDANCE = '本机已安装 dsh-term 插件(DSH Web GUI 的面板式终端):用户可在聊天区打开本地终端(真实 PTY,默认 powershell/bash),多标签并存、会话持久;用户提到「终端 / 打开终端 / 执行命令」时即指本插件,请据此协作。'
19
+
20
+ /**
21
+ * Mount the PTY service and its routes.
22
+ * @param ctx - context carrying the webServer service.
23
+ */
24
+ export const apply = mountOnce('@lijian-ui/dsh-term', applyImpl)
25
+
26
+ function applyImpl(ctx: Context): void {
27
+ const pty = new PtyService()
28
+ // Route registration + pty teardown both ride the effect fiber: the effect
29
+ // callback runs immediately and its return value is the fiber disposer.
30
+ ctx.effect(() => {
31
+ const disposeRoutes = registerTermRoutes(ctx, pty)
32
+ return () => {
33
+ disposeRoutes()
34
+ pty.dispose()
35
+ }
36
+ }, 'dsh-term: routes + pty lifecycle')
37
+ }