dsh-terminal-panel 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/LICENSE +21 -0
- package/README.md +117 -0
- package/README_EN.md +117 -0
- package/assets/banner.svg +32 -0
- package/client.js +1315 -0
- package/cordis.patch.yml +3 -0
- package/host.js +92 -0
- package/impl.js +484 -0
- package/package.json +55 -0
package/cordis.patch.yml
ADDED
package/host.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal-dock — Host half (route carrier).
|
|
3
|
+
*
|
|
4
|
+
* This file only carries the named webserver route and forwards every request
|
|
5
|
+
* to the implementation in `impl.js`, which is pulled in through a cache-busted
|
|
6
|
+
* dynamic import. That indirection exists because a loaded ESM module stays
|
|
7
|
+
* cached for the life of the process: with it, replacing `impl.js` and calling
|
|
8
|
+
* POST /system-terminals/api/__reload brings the new code in live, without a
|
|
9
|
+
* harness restart.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const PREFIX = '/system-terminals/api';
|
|
13
|
+
|
|
14
|
+
/** Hard dependencies: the route needs the carrier, terminals need the process plane. */
|
|
15
|
+
export const inject = ['webServer', 'subprocess'];
|
|
16
|
+
|
|
17
|
+
export function apply(ctx) {
|
|
18
|
+
const web = ctx.webServer || ctx.get('webServer');
|
|
19
|
+
if (!web || typeof web.register !== 'function') {
|
|
20
|
+
console.error('[terminal-dock] webServer unavailable; host half inactive');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let impl = null;
|
|
25
|
+
let loading = null;
|
|
26
|
+
|
|
27
|
+
function load(fresh) {
|
|
28
|
+
if (fresh) {
|
|
29
|
+
impl = null;
|
|
30
|
+
loading = null;
|
|
31
|
+
}
|
|
32
|
+
if (impl) return Promise.resolve(impl);
|
|
33
|
+
if (!loading) {
|
|
34
|
+
const url = new URL('./impl.js', import.meta.url);
|
|
35
|
+
url.searchParams.set('v', String(Date.now()));
|
|
36
|
+
loading = import(url.href).then(
|
|
37
|
+
(mod) => {
|
|
38
|
+
impl = mod;
|
|
39
|
+
loading = null;
|
|
40
|
+
return mod;
|
|
41
|
+
},
|
|
42
|
+
(err) => {
|
|
43
|
+
loading = null;
|
|
44
|
+
throw err;
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return loading;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fail(res, code, message) {
|
|
52
|
+
try {
|
|
53
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
|
|
54
|
+
res.end(JSON.stringify({ ok: false, error: { code: 'host_loader', message } }));
|
|
55
|
+
} catch (err) {
|
|
56
|
+
/* response already gone */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
ctx.effect(() =>
|
|
61
|
+
web.register({
|
|
62
|
+
kind: 'prefix',
|
|
63
|
+
path: PREFIX,
|
|
64
|
+
handler: async (req, res) => {
|
|
65
|
+
const path = String(req.url || '').split('?')[0];
|
|
66
|
+
const tail = path.replace(/^\/+/, '').split('/').filter(Boolean).pop() || '';
|
|
67
|
+
if (tail === '__reload') {
|
|
68
|
+
try {
|
|
69
|
+
await load(true);
|
|
70
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
71
|
+
res.end(JSON.stringify({ ok: true, value: { reloaded: true } }));
|
|
72
|
+
} catch (err) {
|
|
73
|
+
fail(res, 500, err && err.message ? err.message : String(err));
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const mod = await load(false);
|
|
79
|
+
if (!mod || typeof mod.handle !== 'function') {
|
|
80
|
+
fail(res, 500, 'impl.js does not export handle(req, res, ctx)');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
await mod.handle(req, res, ctx);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
fail(res, 500, err && err.message ? err.message : String(err));
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
console.log('[terminal-dock] host half ready (route ' + PREFIX + ')');
|
|
92
|
+
}
|
package/impl.js
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* terminal-dock — host implementation (reloadable).
|
|
3
|
+
*
|
|
4
|
+
* Loaded lazily by host.js through a cache-busted dynamic import, so this file
|
|
5
|
+
* can be replaced and reloaded with POST /system-terminals/api/__reload without
|
|
6
|
+
* restarting the harness.
|
|
7
|
+
*
|
|
8
|
+
* One terminal is one real PTY allocated through the shared `subprocess`
|
|
9
|
+
* service. Output is streamed to the browser as Server-Sent Events and kept in
|
|
10
|
+
* a bounded replay buffer, so a reloading page rebuilds the same screen.
|
|
11
|
+
* Nothing here opens an OS window.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const HISTORY_LIMIT = 262144;
|
|
15
|
+
const KEEPALIVE_MS = 20000;
|
|
16
|
+
const MAX_TERMINALS = 12;
|
|
17
|
+
|
|
18
|
+
/** One state per loaded module instance: reloading the file starts a fresh one. */
|
|
19
|
+
let state = null;
|
|
20
|
+
|
|
21
|
+
export async function handle(req, res, ctx) {
|
|
22
|
+
if (!state) state = createState(ctx);
|
|
23
|
+
return state.handle(req, res);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createState(ctx) {
|
|
27
|
+
/** id -> terminal record. */
|
|
28
|
+
const terminals = new Map();
|
|
29
|
+
const resolvedTools = new Map();
|
|
30
|
+
let seq = 0;
|
|
31
|
+
let cachedRoot;
|
|
32
|
+
let cachedShell;
|
|
33
|
+
|
|
34
|
+
const subprocess = () => {
|
|
35
|
+
const svc = ctx.subprocess || ctx.get('subprocess');
|
|
36
|
+
return svc && typeof svc.spawn === 'function' ? svc : undefined;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
async function toolPath(name) {
|
|
40
|
+
if (resolvedTools.has(name)) return resolvedTools.get(name);
|
|
41
|
+
let path = name;
|
|
42
|
+
const svc = subprocess();
|
|
43
|
+
try {
|
|
44
|
+
if (svc && typeof svc.resolveExecutable === 'function') {
|
|
45
|
+
const resolved = await svc.resolveExecutable(name);
|
|
46
|
+
if (typeof resolved === 'string' && resolved.trim()) path = resolved.trim();
|
|
47
|
+
}
|
|
48
|
+
} catch (err) {
|
|
49
|
+
/* keep the bare name */
|
|
50
|
+
}
|
|
51
|
+
resolvedTools.set(name, path);
|
|
52
|
+
return path;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function pathOf(entry) {
|
|
56
|
+
if (typeof entry === 'string') return entry.trim() || undefined;
|
|
57
|
+
if (!entry || typeof entry !== 'object') return undefined;
|
|
58
|
+
for (const key of ['path', 'root', 'directory', 'cwd', 'folder', 'dir', 'workspacePath']) {
|
|
59
|
+
const value = entry[key];
|
|
60
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function workspacePath() {
|
|
66
|
+
try {
|
|
67
|
+
const registry = ctx.get('workspaceRegistry');
|
|
68
|
+
const list = registry && typeof registry.list === 'function' ? registry.list() : undefined;
|
|
69
|
+
if (Array.isArray(list)) {
|
|
70
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
71
|
+
const found = pathOf(list[i]);
|
|
72
|
+
if (found) return found;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
/* registry absent or not ready */
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function workRoot() {
|
|
82
|
+
if (!cachedRoot) cachedRoot = workspacePath() || '.';
|
|
83
|
+
return cachedRoot;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function resolveCwd(explicit) {
|
|
87
|
+
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
|
|
88
|
+
const known = workspacePath();
|
|
89
|
+
if (known) return known;
|
|
90
|
+
try {
|
|
91
|
+
const fsSvc = ctx.get('fs');
|
|
92
|
+
if (fsSvc && typeof fsSvc.resolve === 'function' && typeof fsSvc.processPath === 'function') {
|
|
93
|
+
const target = await fsSvc.resolve('.');
|
|
94
|
+
const path = target ? fsSvc.processPath(target) : undefined;
|
|
95
|
+
if (typeof path === 'string' && path.trim()) return path.trim();
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
/* fall through */
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether an executable can be resolved in this host's execution world. */
|
|
104
|
+
async function canResolve(name) {
|
|
105
|
+
const svc = subprocess();
|
|
106
|
+
if (!svc || typeof svc.resolveExecutable !== 'function') return false;
|
|
107
|
+
try {
|
|
108
|
+
const resolved = await svc.resolveExecutable(name);
|
|
109
|
+
return typeof resolved === 'string' && resolved.trim().length > 0;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function shellSpec() {
|
|
116
|
+
if (cachedShell) return cachedShell;
|
|
117
|
+
let platform = typeof process !== 'undefined' && process.platform === 'win32' ? 'windows' : 'posix';
|
|
118
|
+
let fallback;
|
|
119
|
+
const svc = subprocess();
|
|
120
|
+
try {
|
|
121
|
+
if (svc && typeof svc.terminalEnvironment === 'function') {
|
|
122
|
+
const env = await svc.terminalEnvironment();
|
|
123
|
+
if (env && env.platform) platform = env.platform;
|
|
124
|
+
if (env && typeof env.defaultShell === 'string' && env.defaultShell.trim()) fallback = env.defaultShell.trim();
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
/* fall back below */
|
|
128
|
+
}
|
|
129
|
+
let shell;
|
|
130
|
+
if (platform === 'windows') {
|
|
131
|
+
for (const candidate of ['pwsh.exe', 'powershell.exe']) {
|
|
132
|
+
if (await canResolve(candidate)) {
|
|
133
|
+
shell = candidate;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!shell) shell = fallback || (platform === 'windows' ? 'cmd.exe' : '/bin/bash');
|
|
139
|
+
const argv = [await toolPath(shell)];
|
|
140
|
+
const base = shell.replace(/\\/g, '/').split('/').pop().toLowerCase();
|
|
141
|
+
if (base.startsWith('powershell') || base === 'pwsh' || base === 'pwsh.exe') argv.push('-NoLogo');
|
|
142
|
+
else if (platform === 'posix') argv.push('-l');
|
|
143
|
+
cachedShell = { platform, shell, argv };
|
|
144
|
+
return cachedShell;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const base64 = (text) => Buffer.from(text, 'utf8').toString('base64');
|
|
148
|
+
|
|
149
|
+
function sendEvent(res, event, payload) {
|
|
150
|
+
try {
|
|
151
|
+
if (event) res.write('event: ' + event + '\n');
|
|
152
|
+
res.write('data: ' + payload + '\n\n');
|
|
153
|
+
return true;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function push(record, text) {
|
|
160
|
+
if (!text) return;
|
|
161
|
+
record.history.push(text);
|
|
162
|
+
record.historyBytes += text.length;
|
|
163
|
+
while (record.historyBytes > HISTORY_LIMIT && record.history.length > 1) {
|
|
164
|
+
record.historyBytes -= record.history.shift().length;
|
|
165
|
+
}
|
|
166
|
+
const payload = base64(text);
|
|
167
|
+
for (const listener of Array.from(record.listeners)) {
|
|
168
|
+
if (!sendEvent(listener, 'data', payload)) record.listeners.delete(listener);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function publicItem(record) {
|
|
173
|
+
return {
|
|
174
|
+
id: record.id,
|
|
175
|
+
title: record.title,
|
|
176
|
+
cwd: record.cwd,
|
|
177
|
+
pid: record.pid || null,
|
|
178
|
+
cols: record.cols,
|
|
179
|
+
rows: record.rows,
|
|
180
|
+
status: record.status,
|
|
181
|
+
exitCode: record.exitCode === undefined ? null : record.exitCode,
|
|
182
|
+
resizable: Boolean(record.resizable),
|
|
183
|
+
createdAt: record.createdAt,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function listTerminals() {
|
|
188
|
+
return { items: Array.from(terminals.values()).map(publicItem), cwd: workspacePath() || null };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function createTerminal(args) {
|
|
192
|
+
const svc = subprocess();
|
|
193
|
+
if (!svc || typeof svc.spawnTerminal !== 'function') {
|
|
194
|
+
throw new Error('this deployment has no terminal backend (subprocess.spawnTerminal unavailable)');
|
|
195
|
+
}
|
|
196
|
+
if (terminals.size >= MAX_TERMINALS) throw new Error('too many terminals open; close one first');
|
|
197
|
+
const spec = await shellSpec();
|
|
198
|
+
const cwd = (await resolveCwd(args && args.cwd)) || workRoot();
|
|
199
|
+
const cols = clampInt(args && args.cols, 20, 400, 100);
|
|
200
|
+
const rows = clampInt(args && args.rows, 5, 120, 28);
|
|
201
|
+
const handle = await svc.spawnTerminal({
|
|
202
|
+
argv: spec.argv,
|
|
203
|
+
cwd,
|
|
204
|
+
env: {
|
|
205
|
+
TERM: 'xterm-256color',
|
|
206
|
+
COLORTERM: 'truecolor',
|
|
207
|
+
LANG: 'en_US.UTF-8',
|
|
208
|
+
},
|
|
209
|
+
rows,
|
|
210
|
+
cols,
|
|
211
|
+
terminalType: 'xterm-256color',
|
|
212
|
+
shellActivity: true,
|
|
213
|
+
graceMs: 3000,
|
|
214
|
+
});
|
|
215
|
+
const n = seq + 1;
|
|
216
|
+
seq = n;
|
|
217
|
+
const record = {
|
|
218
|
+
id: 'term-' + n + '-' + Date.now().toString(36),
|
|
219
|
+
title: 'Terminal ' + n,
|
|
220
|
+
cwd,
|
|
221
|
+
cols,
|
|
222
|
+
rows,
|
|
223
|
+
pid: handle && handle.pid ? handle.pid : null,
|
|
224
|
+
status: 'running',
|
|
225
|
+
exitCode: undefined,
|
|
226
|
+
createdAt: Date.now(),
|
|
227
|
+
resizable: Boolean(handle && typeof handle.resize === 'function'),
|
|
228
|
+
handle,
|
|
229
|
+
history: [],
|
|
230
|
+
historyBytes: 0,
|
|
231
|
+
listeners: new Set(),
|
|
232
|
+
keepalive: null,
|
|
233
|
+
};
|
|
234
|
+
terminals.set(record.id, record);
|
|
235
|
+
|
|
236
|
+
const output = handle && handle.output;
|
|
237
|
+
if (output && typeof output.on === 'function') {
|
|
238
|
+
if (typeof output.setEncoding === 'function') output.setEncoding('utf8');
|
|
239
|
+
output.on('data', (chunk) => push(record, typeof chunk === 'string' ? chunk : String(chunk)));
|
|
240
|
+
output.on('error', () => undefined);
|
|
241
|
+
}
|
|
242
|
+
Promise.resolve(handle.done).then(
|
|
243
|
+
(outcome) => {
|
|
244
|
+
record.status = 'exited';
|
|
245
|
+
record.exitCode = outcome && typeof outcome.exitCode === 'number' ? outcome.exitCode : null;
|
|
246
|
+
for (const listener of Array.from(record.listeners)) sendEvent(listener, 'exit', String(record.exitCode));
|
|
247
|
+
},
|
|
248
|
+
() => {
|
|
249
|
+
record.status = 'exited';
|
|
250
|
+
record.exitCode = null;
|
|
251
|
+
for (const listener of Array.from(record.listeners)) sendEvent(listener, 'exit', 'null');
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
return { item: publicItem(record), shell: spec.shell, platform: spec.platform, resizable: record.resizable };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function writeTerminal(args) {
|
|
258
|
+
const record = requireRecord(args && args.id);
|
|
259
|
+
const data = typeof (args && args.data) === 'string' ? args.data : '';
|
|
260
|
+
if (!data) return {};
|
|
261
|
+
if (record.status !== 'running' || !record.handle) throw new Error('terminal has exited');
|
|
262
|
+
await record.handle.write(data);
|
|
263
|
+
return {};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function resizeTerminal(args) {
|
|
267
|
+
const record = requireRecord(args && args.id);
|
|
268
|
+
const cols = clampInt(args && args.cols, 20, 400, record.cols);
|
|
269
|
+
const rows = clampInt(args && args.rows, 5, 120, record.rows);
|
|
270
|
+
if (record.status === 'running' && record.handle && typeof record.handle.resize === 'function') {
|
|
271
|
+
await record.handle.resize(cols, rows);
|
|
272
|
+
record.cols = cols;
|
|
273
|
+
record.rows = rows;
|
|
274
|
+
}
|
|
275
|
+
return { item: publicItem(record), resized: record.cols === cols && record.rows === rows };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function closeTerminal(args) {
|
|
279
|
+
const record = requireRecord(args && args.id);
|
|
280
|
+
if (record.status === 'running' && record.handle) {
|
|
281
|
+
try {
|
|
282
|
+
await record.handle.terminate();
|
|
283
|
+
} catch (err) {
|
|
284
|
+
/* already gone */
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
record.status = 'exited';
|
|
288
|
+
return { item: publicItem(record) };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function removeTerminal(args) {
|
|
292
|
+
const record = requireRecord(args && args.id);
|
|
293
|
+
if (record.status === 'running') throw new Error('close the terminal before removing it');
|
|
294
|
+
detach(record);
|
|
295
|
+
terminals.delete(record.id);
|
|
296
|
+
return {};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function renameTerminal(args) {
|
|
300
|
+
const record = requireRecord(args && args.id);
|
|
301
|
+
const raw = typeof (args && args.title) === 'string' ? args.title : '';
|
|
302
|
+
const title = raw.replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
303
|
+
if (!title) throw new Error('a terminal name cannot be empty');
|
|
304
|
+
record.title = title;
|
|
305
|
+
return { item: publicItem(record) };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function requireRecord(id) {
|
|
309
|
+
const record = terminals.get(id);
|
|
310
|
+
if (!record) throw new Error('unknown terminal');
|
|
311
|
+
return record;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function detach(record) {
|
|
315
|
+
if (record.keepalive) {
|
|
316
|
+
clearInterval(record.keepalive);
|
|
317
|
+
record.keepalive = null;
|
|
318
|
+
}
|
|
319
|
+
for (const listener of Array.from(record.listeners)) {
|
|
320
|
+
try {
|
|
321
|
+
listener.end();
|
|
322
|
+
} catch (err) {
|
|
323
|
+
/* already closed */
|
|
324
|
+
}
|
|
325
|
+
record.listeners.delete(listener);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function streamTerminal(req, res, record) {
|
|
330
|
+
res.writeHead(200, {
|
|
331
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
332
|
+
'cache-control': 'no-cache, no-store, no-transform',
|
|
333
|
+
connection: 'keep-alive',
|
|
334
|
+
'x-accel-buffering': 'no',
|
|
335
|
+
});
|
|
336
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
337
|
+
res.write(': terminal ' + record.id + '\n\n');
|
|
338
|
+
record.listeners.add(res);
|
|
339
|
+
if (record.history.length) sendEvent(res, 'history', base64(record.history.join('')));
|
|
340
|
+
sendEvent(res, 'status', record.status);
|
|
341
|
+
if (record.status !== 'running') sendEvent(res, 'exit', String(record.exitCode));
|
|
342
|
+
if (!record.keepalive) {
|
|
343
|
+
record.keepalive = setInterval(() => {
|
|
344
|
+
if (!record.listeners.size) return;
|
|
345
|
+
for (const listener of Array.from(record.listeners)) {
|
|
346
|
+
try {
|
|
347
|
+
listener.write(': keepalive\n\n');
|
|
348
|
+
} catch (err) {
|
|
349
|
+
record.listeners.delete(listener);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}, KEEPALIVE_MS);
|
|
353
|
+
}
|
|
354
|
+
const drop = () => {
|
|
355
|
+
record.listeners.delete(res);
|
|
356
|
+
if (!record.listeners.size && record.keepalive) {
|
|
357
|
+
clearInterval(record.keepalive);
|
|
358
|
+
record.keepalive = null;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
req.on('close', drop);
|
|
362
|
+
req.on('error', drop);
|
|
363
|
+
res.on('error', drop);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function clampInt(value, min, max, fallback) {
|
|
367
|
+
const number = typeof value === 'number' ? value : Number.parseInt(value, 10);
|
|
368
|
+
if (!Number.isFinite(number)) return fallback;
|
|
369
|
+
return Math.max(min, Math.min(max, Math.round(number)));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function readBody(req) {
|
|
373
|
+
return new Promise((resolve) => {
|
|
374
|
+
let size = 0;
|
|
375
|
+
const chunks = [];
|
|
376
|
+
req.on('data', (chunk) => {
|
|
377
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8');
|
|
378
|
+
size += buffer.length;
|
|
379
|
+
if (size > 1048576) {
|
|
380
|
+
resolve(undefined);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
chunks.push(buffer);
|
|
384
|
+
});
|
|
385
|
+
req.on('end', () => {
|
|
386
|
+
// Decode once: a multi-byte character must not be split per TCP chunk.
|
|
387
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
388
|
+
if (!text.trim()) {
|
|
389
|
+
resolve({});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
const parsed = JSON.parse(text);
|
|
394
|
+
resolve(parsed && typeof parsed === 'object' ? parsed : {});
|
|
395
|
+
} catch (err) {
|
|
396
|
+
resolve(undefined);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
req.on('error', () => resolve(undefined));
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function send(res, status, payload) {
|
|
404
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
405
|
+
res.end(JSON.stringify(payload));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const routes = {
|
|
409
|
+
health: async () => {
|
|
410
|
+
const spec = await shellSpec();
|
|
411
|
+
return {
|
|
412
|
+
version: 5,
|
|
413
|
+
platform: spec.platform,
|
|
414
|
+
shell: spec.shell,
|
|
415
|
+
cwd: workRoot(),
|
|
416
|
+
pty: typeof (subprocess() || {}).spawnTerminal === 'function',
|
|
417
|
+
terminals: terminals.size,
|
|
418
|
+
};
|
|
419
|
+
},
|
|
420
|
+
list: () => listTerminals(),
|
|
421
|
+
create: (args) => createTerminal(args),
|
|
422
|
+
write: (args) => writeTerminal(args),
|
|
423
|
+
resize: (args) => resizeTerminal(args),
|
|
424
|
+
close: (args) => closeTerminal(args),
|
|
425
|
+
rename: (args) => renameTerminal(args),
|
|
426
|
+
remove: (args) => removeTerminal(args),
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
async function handle(req, res) {
|
|
430
|
+
const url = String(req.url || '');
|
|
431
|
+
const path = url.split('?')[0];
|
|
432
|
+
const key = path.replace(/^\/+/, '').split('/').filter(Boolean).pop() || 'list';
|
|
433
|
+
if (key === 'stream') {
|
|
434
|
+
const query = url.indexOf('?') === -1 ? '' : url.slice(url.indexOf('?') + 1);
|
|
435
|
+
const match = /(?:^|&)id=([^&]*)/.exec(query);
|
|
436
|
+
const id = match ? decodeURIComponent(match[1]) : '';
|
|
437
|
+
const record = terminals.get(id);
|
|
438
|
+
if (!record) {
|
|
439
|
+
send(res, 404, { ok: false, error: { code: 'not_found', message: 'unknown terminal' } });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
streamTerminal(req, res, record);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (!Object.prototype.hasOwnProperty.call(routes, key)) {
|
|
446
|
+
send(res, 404, { ok: false, error: { code: 'not_found', message: 'unknown method ' + JSON.stringify(key) } });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (req.method !== 'GET' && req.method !== 'POST') {
|
|
450
|
+
send(res, 405, { ok: false, error: { code: 'method_not_allowed', message: 'use GET or POST' } });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
let args = {};
|
|
454
|
+
if (req.method === 'POST') {
|
|
455
|
+
args = await readBody(req);
|
|
456
|
+
if (args === undefined) {
|
|
457
|
+
send(res, 400, { ok: false, error: { code: 'bad_body', message: 'request body must be JSON' } });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
send(res, 200, { ok: true, value: await routes[key](args) });
|
|
463
|
+
} catch (err) {
|
|
464
|
+
send(res, 200, {
|
|
465
|
+
ok: false,
|
|
466
|
+
error: { code: 'failed', message: err && err.message ? err.message : String(err) },
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
ctx.effect(() => () => {
|
|
472
|
+
for (const record of Array.from(terminals.values())) {
|
|
473
|
+
detach(record);
|
|
474
|
+
try {
|
|
475
|
+
if (record.handle && record.status === 'running') record.handle.terminate();
|
|
476
|
+
} catch (err) {
|
|
477
|
+
/* already gone */
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
terminals.clear();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
return { handle, terminals };
|
|
484
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-terminal-panel",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "In-page terminal panel for DeepSeek Harness: real PTY terminals opened from the sidebar, with tabs, rename and live replay. DSH 网页内的终端面板插件。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./host.js",
|
|
9
|
+
"./client": "./client.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"host.js",
|
|
13
|
+
"impl.js",
|
|
14
|
+
"client.js",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"assets",
|
|
17
|
+
"README.md",
|
|
18
|
+
"README_EN.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"dsh": {
|
|
22
|
+
"bundle": {
|
|
23
|
+
"patch": "./cordis.patch.yml"
|
|
24
|
+
},
|
|
25
|
+
"client": {
|
|
26
|
+
"platform": "web",
|
|
27
|
+
"immediately": true,
|
|
28
|
+
"inject": []
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"deepseek",
|
|
33
|
+
"deepseek-harness",
|
|
34
|
+
"dsh",
|
|
35
|
+
"dsh-plugin",
|
|
36
|
+
"plugin",
|
|
37
|
+
"cordis",
|
|
38
|
+
"terminal",
|
|
39
|
+
"pty",
|
|
40
|
+
"web-terminal",
|
|
41
|
+
"sidebar"
|
|
42
|
+
],
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/BaiZhi967/dsh-plugin-terminal-panel.git"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": "^22.19.0 || >=24.0.0",
|
|
49
|
+
"dsh": ">=0.1.6-alpha.2"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"registry": "https://registry.npmjs.org/"
|
|
54
|
+
}
|
|
55
|
+
}
|