@hyzyn/dsh-tty 0.1.1
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 +123 -0
- package/client.js +285 -0
- package/cordis.patch.yml +8 -0
- package/lib/index.d.ts +47 -0
- package/lib/index.js +768 -0
- package/lib/index.js.map +1 -0
- package/package.json +65 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { PassThrough } from 'node:stream';
|
|
5
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
6
|
+
import { definePlugin } from '@hyzyn/dsh-kit';
|
|
7
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
8
|
+
/** 与「设置 → 插件 → 终端面板」卡片表单对齐的 schema。 */
|
|
9
|
+
const TTY_SETTINGS_SCHEMA = z.object({
|
|
10
|
+
enabled: z.boolean().default(true),
|
|
11
|
+
announceToAgent: z.boolean().default(true),
|
|
12
|
+
maxSessions: z.natural().max(16).default(4),
|
|
13
|
+
shell: z.string().default(''),
|
|
14
|
+
term: z.string().default('xterm-256color'),
|
|
15
|
+
colorTerm: z.string().default('truecolor'),
|
|
16
|
+
cwd: z.string().default(''),
|
|
17
|
+
});
|
|
18
|
+
/* ------------------------------------------------------------------ *
|
|
19
|
+
* 常量
|
|
20
|
+
* ------------------------------------------------------------------ */
|
|
21
|
+
const WS_PATH = '/api/dsh-tty/ws';
|
|
22
|
+
const DEFAULT_MAX_SESSIONS = 4;
|
|
23
|
+
/** 下行背压阈值(ws.bufferedAmount 字节)。 */
|
|
24
|
+
const BACKPRESSURE_HIGH = 512 * 1024;
|
|
25
|
+
const BACKPRESSURE_LOW = 128 * 1024;
|
|
26
|
+
const SID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
27
|
+
const BUFFER_CAP = 256 * 1024;
|
|
28
|
+
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页;新标签默认在当前会话工作目录打开,工作目录可随当前会话切换。长驻进程(dev server、watch、交互式程序)应引导用户到终端面板里运行,不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑」时引导其打开该面板。agent 侧也有配套工具:tty_list 列出活跃终端会话,tty_capture 读取会话近期输出(可查看 dev server/NPM 日志),tty_send 向会话发送按键——操作会实时显示在用户终端里。';
|
|
29
|
+
/** 可热更新的运行时配置(settings/updated 动态应用)。 */
|
|
30
|
+
class LiveConfig {
|
|
31
|
+
shell;
|
|
32
|
+
term;
|
|
33
|
+
colorTerm;
|
|
34
|
+
cwd;
|
|
35
|
+
constructor(init) {
|
|
36
|
+
this.shell = init.shell;
|
|
37
|
+
this.term = init.term;
|
|
38
|
+
this.colorTerm = init.colorTerm;
|
|
39
|
+
this.cwd = init.cwd;
|
|
40
|
+
}
|
|
41
|
+
/** 合并部分更新;空字符串/undefined 保持原值。 */
|
|
42
|
+
apply(partial) {
|
|
43
|
+
if (typeof partial.shell === 'string' && partial.shell.trim() !== '')
|
|
44
|
+
this.shell = partial.shell.trim();
|
|
45
|
+
if (typeof partial.term === 'string' && partial.term.trim() !== '')
|
|
46
|
+
this.term = partial.term.trim();
|
|
47
|
+
if (typeof partial.colorTerm === 'string' && partial.colorTerm.trim() !== '')
|
|
48
|
+
this.colorTerm = partial.colorTerm.trim();
|
|
49
|
+
if (typeof partial.cwd === 'string' && partial.cwd.trim() !== '')
|
|
50
|
+
this.cwd = partial.cwd.trim();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/* ------------------------------------------------------------------ *
|
|
54
|
+
* 工具
|
|
55
|
+
* ------------------------------------------------------------------ */
|
|
56
|
+
/** best-effort 终止:terminate() 抛「幸存者」竞态时降级为对顶层 shell 直接 SIGKILL。 */
|
|
57
|
+
async function forceKill(handle) {
|
|
58
|
+
try {
|
|
59
|
+
await handle.terminate();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
try {
|
|
63
|
+
handle.terminal.kill('SIGKILL');
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
/* 已退出 */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* shell argv。node-pty 的 name 优先于 env.TERM,而 DSH 硬编码 name:"dumb",
|
|
72
|
+
* 只能在 exec 真正的 shell 之前 export(M0 A2 实测结论)。
|
|
73
|
+
*/
|
|
74
|
+
function shellArgv(shell, term, colorTerm) {
|
|
75
|
+
return [shell, '-c', `export TERM='${term}'; export COLORTERM='${colorTerm}'; exec "${shell}"`];
|
|
76
|
+
}
|
|
77
|
+
function send(ws, msg) {
|
|
78
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
79
|
+
return;
|
|
80
|
+
ws.send(JSON.stringify(msg));
|
|
81
|
+
}
|
|
82
|
+
/** upgrade 路由的 loopback 信任围栏(与 dsh-mcp 的 HTTP 围栏同思路,socket 版)。 */
|
|
83
|
+
function isLoopbackUpgrade(req) {
|
|
84
|
+
const address = req.socket.remoteAddress;
|
|
85
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
|
|
86
|
+
return false;
|
|
87
|
+
const host = req.headers.host;
|
|
88
|
+
if (typeof host !== 'string')
|
|
89
|
+
return false;
|
|
90
|
+
let hostUrl;
|
|
91
|
+
try {
|
|
92
|
+
hostUrl = new URL('http://' + host);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]')
|
|
98
|
+
return false;
|
|
99
|
+
if (req.headers['sec-fetch-site'] === 'cross-site')
|
|
100
|
+
return false;
|
|
101
|
+
const origin = req.headers.origin;
|
|
102
|
+
if (origin === undefined)
|
|
103
|
+
return true;
|
|
104
|
+
try {
|
|
105
|
+
return new URL(origin).host === hostUrl.host;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/* ------------------------------------------------------------------ *
|
|
112
|
+
* 会话管理
|
|
113
|
+
* ------------------------------------------------------------------ */
|
|
114
|
+
class SessionManager {
|
|
115
|
+
sessions = new Map();
|
|
116
|
+
limit;
|
|
117
|
+
constructor(maxSessions) {
|
|
118
|
+
this.limit = maxSessions;
|
|
119
|
+
}
|
|
120
|
+
get limitValue() {
|
|
121
|
+
return this.limit;
|
|
122
|
+
}
|
|
123
|
+
/** 配置热生效时调整上限(1~16)。 */
|
|
124
|
+
setLimit(maxSessions) {
|
|
125
|
+
this.limit = Math.max(1, Math.min(16, maxSessions));
|
|
126
|
+
}
|
|
127
|
+
get count() {
|
|
128
|
+
return this.sessions.size;
|
|
129
|
+
}
|
|
130
|
+
canSpawn() {
|
|
131
|
+
return this.sessions.size < this.limit;
|
|
132
|
+
}
|
|
133
|
+
add(session) {
|
|
134
|
+
this.sessions.set(session.id, session);
|
|
135
|
+
}
|
|
136
|
+
remove(id) {
|
|
137
|
+
this.sessions.delete(id);
|
|
138
|
+
}
|
|
139
|
+
get(id) {
|
|
140
|
+
return this.sessions.get(id);
|
|
141
|
+
}
|
|
142
|
+
/** agent 工具用的只读快照。 */
|
|
143
|
+
list() {
|
|
144
|
+
return [...this.sessions.values()].map((session) => ({
|
|
145
|
+
sid: session.id,
|
|
146
|
+
pid: session.handle.pid,
|
|
147
|
+
cwd: session.cwd,
|
|
148
|
+
startedAt: session.startedAt,
|
|
149
|
+
lastOutputAt: session.lastOutputAt,
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
async disposeAll() {
|
|
153
|
+
const all = [...this.sessions.values()];
|
|
154
|
+
this.sessions.clear();
|
|
155
|
+
await Promise.all(all.map((session) => forceKill(session.handle)));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/* ------------------------------------------------------------------ *
|
|
159
|
+
* WebSocket 连接处理
|
|
160
|
+
* ------------------------------------------------------------------ */
|
|
161
|
+
class TtyServer {
|
|
162
|
+
ctx;
|
|
163
|
+
sessions;
|
|
164
|
+
options;
|
|
165
|
+
wss = new WebSocketServer({ noServer: true });
|
|
166
|
+
constructor(ctx, sessions, options) {
|
|
167
|
+
this.ctx = ctx;
|
|
168
|
+
this.sessions = sessions;
|
|
169
|
+
this.options = options;
|
|
170
|
+
this.wss.on('connection', (ws) => this.onConnection(ws));
|
|
171
|
+
}
|
|
172
|
+
/** registerUpgrade 的 handler(loopback 围栏 + ws 握手)。 */
|
|
173
|
+
handleUpgrade(req, socket, head) {
|
|
174
|
+
if (!isLoopbackUpgrade(req)) {
|
|
175
|
+
socket.destroy();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
179
|
+
this.wss.emit('connection', ws, req);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
onConnection(ws) {
|
|
183
|
+
/** 本连接上的会话表(sid → session);单连接多会话(标签页)。 */
|
|
184
|
+
const local = new Map();
|
|
185
|
+
const cleanupAll = async () => {
|
|
186
|
+
const all = [...local.values()];
|
|
187
|
+
local.clear();
|
|
188
|
+
await Promise.all(all.map(async (session) => {
|
|
189
|
+
session.closed = true;
|
|
190
|
+
this.sessions.remove(session.id);
|
|
191
|
+
await forceKill(session.handle);
|
|
192
|
+
}));
|
|
193
|
+
};
|
|
194
|
+
ws.on('message', (raw) => {
|
|
195
|
+
let msg;
|
|
196
|
+
try {
|
|
197
|
+
msg = JSON.parse(raw.toString());
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
void this.handleMessage(ws, msg, local, cleanupAll);
|
|
203
|
+
});
|
|
204
|
+
ws.on('close', () => {
|
|
205
|
+
void cleanupAll();
|
|
206
|
+
});
|
|
207
|
+
ws.on('error', (error) => {
|
|
208
|
+
this.ctx.logger.warn('[dsh-tty] ws error: ' + error.message);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* 解析帧里的 sid。返回:
|
|
213
|
+
* { sid } 目标会话;
|
|
214
|
+
* { unknown } 显式 sid 但本连接无此会话(客户端竞态,如 resize 先于
|
|
215
|
+
* spawn 就绪到达;调用方应静默忽略,而不是报错);
|
|
216
|
+
* undefined 已发送错误帧(非法 sid / sid 缺省但无法唯一路由)。
|
|
217
|
+
*/
|
|
218
|
+
resolveSid(ws, msg, local) {
|
|
219
|
+
const raw = msg.sid;
|
|
220
|
+
if (typeof raw === 'string' && raw !== '') {
|
|
221
|
+
if (!SID_RE.test(raw)) {
|
|
222
|
+
send(ws, { t: 'error', m: '非法 sid' });
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
if (!local.has(raw))
|
|
226
|
+
return { unknown: true };
|
|
227
|
+
return { sid: raw };
|
|
228
|
+
}
|
|
229
|
+
if (local.size === 1)
|
|
230
|
+
return { sid: [...local.keys()][0] };
|
|
231
|
+
send(ws, { t: 'error', m: local.size === 0 ? '没有可用会话(先发 spawn)' : '存在多个会话,请指定 sid' });
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
async handleMessage(ws, msg, local, cleanupAll) {
|
|
235
|
+
try {
|
|
236
|
+
if (msg.t === 'spawn') {
|
|
237
|
+
const sid = typeof msg.sid === 'string' && msg.sid !== '' ? msg.sid : randomUUID();
|
|
238
|
+
if (!SID_RE.test(sid)) {
|
|
239
|
+
send(ws, { t: 'error', m: '非法 sid' });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (local.has(sid)) {
|
|
243
|
+
send(ws, { t: 'error', sid, m: 'sid 已存在' });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (!this.sessions.canSpawn()) {
|
|
247
|
+
send(ws, { t: 'error', sid, m: `会话数已达上限(${this.sessions.limitValue})` });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// 客户端(当前会话)cwd 优先;校验存在性,避免 node-pty 抛难懂错误
|
|
251
|
+
const cwd = typeof msg.cwd === 'string' && msg.cwd.trim() !== '' ? msg.cwd.trim() : this.options.cwd;
|
|
252
|
+
if (!existsSync(cwd)) {
|
|
253
|
+
send(ws, { t: 'error', sid, m: `cwd 不存在: ${cwd}` });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const subprocess = this.ctx.get('subprocess');
|
|
257
|
+
if (subprocess === undefined) {
|
|
258
|
+
send(ws, { t: 'error', sid, m: 'subprocess 服务不可用' });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const handle = await subprocess.spawnTerminal({
|
|
262
|
+
argv: shellArgv(this.options.shell, this.options.term, this.options.colorTerm),
|
|
263
|
+
rows: Number(msg.rows) || 24,
|
|
264
|
+
cols: Number(msg.cols) || 80,
|
|
265
|
+
cwd,
|
|
266
|
+
env: { TERM: this.options.term, COLORTERM: this.options.colorTerm },
|
|
267
|
+
graceMs: 5000,
|
|
268
|
+
});
|
|
269
|
+
const next = {
|
|
270
|
+
id: sid,
|
|
271
|
+
handle,
|
|
272
|
+
ws,
|
|
273
|
+
closed: false,
|
|
274
|
+
paused: false,
|
|
275
|
+
cwd,
|
|
276
|
+
startedAt: Date.now(),
|
|
277
|
+
lastOutputAt: Date.now(),
|
|
278
|
+
buffer: '',
|
|
279
|
+
};
|
|
280
|
+
local.set(sid, next);
|
|
281
|
+
this.sessions.add(next);
|
|
282
|
+
send(ws, { t: 'ready', sid, pid: handle.pid });
|
|
283
|
+
this.attachOutput(next);
|
|
284
|
+
handle.done.then((outcome) => {
|
|
285
|
+
// kill 主动关闭时会话可能已被移出 local,用 exitSent 保证 exit 帧恰好一次
|
|
286
|
+
if (next.exitSent === true)
|
|
287
|
+
return;
|
|
288
|
+
next.exitSent = true;
|
|
289
|
+
next.closed = true;
|
|
290
|
+
local.delete(sid);
|
|
291
|
+
this.sessions.remove(sid);
|
|
292
|
+
send(ws, { t: 'exit', sid, code: outcome.exitCode, signal: outcome.signal });
|
|
293
|
+
}).catch(() => { });
|
|
294
|
+
}
|
|
295
|
+
else if (msg.t === 'input') {
|
|
296
|
+
const resolved = this.resolveSid(ws, msg, local);
|
|
297
|
+
if (resolved === undefined || 'unknown' in resolved)
|
|
298
|
+
return;
|
|
299
|
+
const session = local.get(resolved.sid);
|
|
300
|
+
if (session !== undefined)
|
|
301
|
+
await session.handle.write(String(msg.d ?? ''));
|
|
302
|
+
}
|
|
303
|
+
else if (msg.t === 'resize') {
|
|
304
|
+
const resolved = this.resolveSid(ws, msg, local);
|
|
305
|
+
if (resolved === undefined || 'unknown' in resolved)
|
|
306
|
+
return;
|
|
307
|
+
const session = local.get(resolved.sid);
|
|
308
|
+
if (session !== undefined) {
|
|
309
|
+
session.handle.terminal.resize(Number(msg.cols) || 80, Number(msg.rows) || 24);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
else if (msg.t === 'kill') {
|
|
313
|
+
const resolved = this.resolveSid(ws, msg, local);
|
|
314
|
+
if (resolved === undefined || 'unknown' in resolved)
|
|
315
|
+
return;
|
|
316
|
+
const session = local.get(resolved.sid);
|
|
317
|
+
if (session === undefined)
|
|
318
|
+
return;
|
|
319
|
+
session.closed = true;
|
|
320
|
+
local.delete(resolved.sid);
|
|
321
|
+
this.sessions.remove(resolved.sid);
|
|
322
|
+
await forceKill(session.handle);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
send(ws, { t: 'error', m: error instanceof Error ? error.message : String(error) });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/** 输出下行 + 基于 ws.bufferedAmount 的背压(暂停/恢复 PassThrough)。 */
|
|
330
|
+
attachOutput(session) {
|
|
331
|
+
const output = session.handle.output;
|
|
332
|
+
const onData = (chunk) => {
|
|
333
|
+
if (session.closed)
|
|
334
|
+
return;
|
|
335
|
+
const text = chunk.toString('utf8');
|
|
336
|
+
session.lastOutputAt = Date.now();
|
|
337
|
+
session.buffer = (session.buffer + text).slice(-BUFFER_CAP);
|
|
338
|
+
const ws = session.ws;
|
|
339
|
+
const sid = session.id;
|
|
340
|
+
ws.send(JSON.stringify({ t: 'data', sid, d: text }), () => {
|
|
341
|
+
if (session.paused && ws.bufferedAmount < BACKPRESSURE_LOW && output.readableFlowing === false) {
|
|
342
|
+
output.resume();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
if (!session.paused && ws.bufferedAmount > BACKPRESSURE_HIGH) {
|
|
346
|
+
session.paused = true;
|
|
347
|
+
output.pause();
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
output.on('data', onData);
|
|
351
|
+
}
|
|
352
|
+
close() {
|
|
353
|
+
for (const client of this.wss.clients) {
|
|
354
|
+
try {
|
|
355
|
+
client.close();
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
/* 已关闭 */
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/* ------------------------------------------------------------------ *
|
|
364
|
+
* 插件本体
|
|
365
|
+
* ------------------------------------------------------------------ */
|
|
366
|
+
/** HTTP 路由的 loopback 信任围栏(与 dsh-mcp 同思路)。 */
|
|
367
|
+
function isLoopbackHttp(req) {
|
|
368
|
+
const address = req.socket.remoteAddress;
|
|
369
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
|
|
370
|
+
return false;
|
|
371
|
+
const host = req.headers.host;
|
|
372
|
+
if (typeof host !== 'string')
|
|
373
|
+
return false;
|
|
374
|
+
let hostUrl;
|
|
375
|
+
try {
|
|
376
|
+
hostUrl = new URL('http://' + host);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]')
|
|
382
|
+
return false;
|
|
383
|
+
if (req.headers['sec-fetch-site'] === 'cross-site')
|
|
384
|
+
return false;
|
|
385
|
+
const origin = req.headers.origin;
|
|
386
|
+
if (origin === undefined)
|
|
387
|
+
return true;
|
|
388
|
+
try {
|
|
389
|
+
return new URL(origin).host === hostUrl.host;
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function writeJson(res, status, body) {
|
|
396
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
|
|
397
|
+
res.end(JSON.stringify(body));
|
|
398
|
+
}
|
|
399
|
+
async function readJsonBody(req) {
|
|
400
|
+
const chunks = [];
|
|
401
|
+
let size = 0;
|
|
402
|
+
try {
|
|
403
|
+
for await (const chunk of req) {
|
|
404
|
+
size += chunk.length;
|
|
405
|
+
if (size > 512 * 1024)
|
|
406
|
+
return undefined;
|
|
407
|
+
chunks.push(chunk);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
415
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : undefined;
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const plugin = definePlugin({
|
|
422
|
+
name: 'tty',
|
|
423
|
+
// 声明 inject:tools 服务只有声明式 inject 才能解析(动态 ctx.inject/ctx.get
|
|
424
|
+
// 均拿不到,实测 mcp-client 同款模式),声明后 ctx.get('tools') 才能取到。
|
|
425
|
+
inject: ['tools'],
|
|
426
|
+
apply(ctx, config) {
|
|
427
|
+
if (config?.enabled === false)
|
|
428
|
+
return;
|
|
429
|
+
const live = new LiveConfig({
|
|
430
|
+
shell: config?.shell?.trim() || process.env.SHELL || '/bin/zsh',
|
|
431
|
+
term: config?.term?.trim() || 'xterm-256color',
|
|
432
|
+
colorTerm: config?.colorTerm?.trim() || 'truecolor',
|
|
433
|
+
cwd: config?.cwd?.trim() || process.cwd(),
|
|
434
|
+
});
|
|
435
|
+
const sessions = new SessionManager(config?.maxSessions ?? DEFAULT_MAX_SESSIONS);
|
|
436
|
+
const server = new TtyServer(ctx, sessions, live);
|
|
437
|
+
const stateRef = { enabled: true, announceToAgent: config?.announceToAgent !== false, toolsRegistered: false };
|
|
438
|
+
let settingsScope;
|
|
439
|
+
const snapshot = () => ({
|
|
440
|
+
enabled: stateRef.enabled,
|
|
441
|
+
announceToAgent: stateRef.announceToAgent,
|
|
442
|
+
maxSessions: sessions.limitValue,
|
|
443
|
+
shell: live.shell,
|
|
444
|
+
term: live.term,
|
|
445
|
+
colorTerm: live.colorTerm,
|
|
446
|
+
cwd: live.cwd,
|
|
447
|
+
toolsRegistered: stateRef.toolsRegistered,
|
|
448
|
+
});
|
|
449
|
+
/** 规范化并应用一份配置补丁(settings/updated 事件与 HTTP POST 共用;幂等)。 */
|
|
450
|
+
const applyPatch = (section) => {
|
|
451
|
+
live.apply({
|
|
452
|
+
shell: typeof section.shell === 'string' ? section.shell : undefined,
|
|
453
|
+
term: typeof section.term === 'string' ? section.term : undefined,
|
|
454
|
+
colorTerm: typeof section.colorTerm === 'string' ? section.colorTerm : undefined,
|
|
455
|
+
cwd: typeof section.cwd === 'string' ? section.cwd : undefined,
|
|
456
|
+
});
|
|
457
|
+
if (typeof section.maxSessions === 'number' && Number.isInteger(section.maxSessions) && section.maxSessions >= 1 && section.maxSessions <= 16) {
|
|
458
|
+
sessions.setLimit(section.maxSessions);
|
|
459
|
+
}
|
|
460
|
+
if (typeof section.enabled === 'boolean')
|
|
461
|
+
stateRef.enabled = section.enabled;
|
|
462
|
+
if (typeof section.announceToAgent === 'boolean')
|
|
463
|
+
stateRef.announceToAgent = section.announceToAgent;
|
|
464
|
+
console.log(`[dsh-tty] config applied (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue})`);
|
|
465
|
+
};
|
|
466
|
+
/** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
|
|
467
|
+
const normalizePatch = (input) => {
|
|
468
|
+
const patch = {};
|
|
469
|
+
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd']);
|
|
470
|
+
for (const key of Object.keys(input)) {
|
|
471
|
+
if (!known.has(key))
|
|
472
|
+
return { error: '未知配置项: ' + key };
|
|
473
|
+
}
|
|
474
|
+
if (input.enabled !== undefined) {
|
|
475
|
+
if (typeof input.enabled !== 'boolean')
|
|
476
|
+
return { error: 'enabled 必须是布尔值' };
|
|
477
|
+
patch.enabled = input.enabled;
|
|
478
|
+
}
|
|
479
|
+
if (input.announceToAgent !== undefined) {
|
|
480
|
+
if (typeof input.announceToAgent !== 'boolean')
|
|
481
|
+
return { error: 'announceToAgent 必须是布尔值' };
|
|
482
|
+
patch.announceToAgent = input.announceToAgent;
|
|
483
|
+
}
|
|
484
|
+
if (input.maxSessions !== undefined) {
|
|
485
|
+
const value = Number(input.maxSessions);
|
|
486
|
+
if (!Number.isInteger(value) || value < 1 || value > 16)
|
|
487
|
+
return { error: 'maxSessions 必须是 1~16 的整数' };
|
|
488
|
+
patch.maxSessions = value;
|
|
489
|
+
}
|
|
490
|
+
for (const key of ['shell', 'term', 'colorTerm']) {
|
|
491
|
+
if (input[key] === undefined)
|
|
492
|
+
continue;
|
|
493
|
+
if (typeof input[key] !== 'string')
|
|
494
|
+
return { error: key + ' 必须是字符串' };
|
|
495
|
+
if (input[key].trim() !== '')
|
|
496
|
+
patch[key] = input[key].trim();
|
|
497
|
+
}
|
|
498
|
+
if (input.cwd !== undefined) {
|
|
499
|
+
if (typeof input.cwd !== 'string')
|
|
500
|
+
return { error: 'cwd 必须是字符串' };
|
|
501
|
+
const cwd = input.cwd.trim();
|
|
502
|
+
if (cwd !== '') {
|
|
503
|
+
if (!existsSync(cwd))
|
|
504
|
+
return { error: 'cwd 不存在: ' + cwd };
|
|
505
|
+
patch.cwd = cwd;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { patch };
|
|
509
|
+
};
|
|
510
|
+
// webServer:WS upgrade 路由 + 配置读写路由(/api/dsh-tty/config)
|
|
511
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
512
|
+
webCtx.effect(() => {
|
|
513
|
+
const webServer = webCtx.webServer;
|
|
514
|
+
const disposers = [];
|
|
515
|
+
disposers.push(webServer.registerUpgrade({
|
|
516
|
+
path: WS_PATH,
|
|
517
|
+
handler: (req, socket, head) => server.handleUpgrade(req, socket, head),
|
|
518
|
+
}));
|
|
519
|
+
disposers.push(webServer.register({
|
|
520
|
+
kind: 'exact',
|
|
521
|
+
path: '/api/dsh-tty/config',
|
|
522
|
+
handler: async (req, res) => {
|
|
523
|
+
if (!isLoopbackHttp(req)) {
|
|
524
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (req.method === 'GET') {
|
|
528
|
+
writeJson(res, 200, { ok: true, config: snapshot() });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (req.method !== 'POST') {
|
|
532
|
+
writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const body = await readJsonBody(req);
|
|
536
|
+
if (body === undefined) {
|
|
537
|
+
writeJson(res, 400, { error: 'invalid JSON body' });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const normalized = normalizePatch(body);
|
|
541
|
+
if (normalized.error !== undefined) {
|
|
542
|
+
writeJson(res, 400, { error: normalized.error });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const patch = normalized.patch ?? {};
|
|
546
|
+
const scope = settingsScope;
|
|
547
|
+
if (scope !== undefined) {
|
|
548
|
+
try {
|
|
549
|
+
// 官方持久化通道:写入 settings 命名空间(dsh-settings-file),
|
|
550
|
+
// 成功后触发 settings/updated → applyPatch 热应用
|
|
551
|
+
await scope.update(patch);
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
writeJson(res, 500, { error: '保存配置失败: ' + (error instanceof Error ? error.message : String(error)) });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// 无 settings 服务(或 stub)时直接应用;有服务时也再应用一次(幂等)
|
|
559
|
+
applyPatch(patch);
|
|
560
|
+
writeJson(res, 200, { ok: true, config: snapshot() });
|
|
561
|
+
},
|
|
562
|
+
}));
|
|
563
|
+
return () => {
|
|
564
|
+
server.close();
|
|
565
|
+
for (const dispose of disposers) {
|
|
566
|
+
try {
|
|
567
|
+
dispose();
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
/* 路由已释放 */
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
}, 'dsh-tty: web routes');
|
|
575
|
+
});
|
|
576
|
+
// settings 命名空间:注册 + 启动合并持久化值 + settings/updated 热应用
|
|
577
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
578
|
+
settingsCtx.effect(() => {
|
|
579
|
+
const settings = settingsCtx.settings;
|
|
580
|
+
const scope = settings.register('tty', TTY_SETTINGS_SCHEMA);
|
|
581
|
+
settingsScope = scope;
|
|
582
|
+
// 启动合并:字符串字段非空才覆盖;maxSessions/布尔用「非默认值才覆盖」启发式
|
|
583
|
+
//(schema 默认值会混入 resolved,无法区分「显式保存的 4」与「从未保存」)。
|
|
584
|
+
const stored = scope.get();
|
|
585
|
+
const startup = {};
|
|
586
|
+
if (typeof stored.shell === 'string' && stored.shell.trim() !== '')
|
|
587
|
+
startup.shell = stored.shell;
|
|
588
|
+
if (typeof stored.term === 'string' && stored.term.trim() !== '')
|
|
589
|
+
startup.term = stored.term;
|
|
590
|
+
if (typeof stored.colorTerm === 'string' && stored.colorTerm.trim() !== '')
|
|
591
|
+
startup.colorTerm = stored.colorTerm;
|
|
592
|
+
if (typeof stored.cwd === 'string' && stored.cwd.trim() !== '')
|
|
593
|
+
startup.cwd = stored.cwd;
|
|
594
|
+
if (stored.maxSessions !== 4 && typeof stored.maxSessions === 'number')
|
|
595
|
+
startup.maxSessions = stored.maxSessions;
|
|
596
|
+
if (stored.enabled === false)
|
|
597
|
+
startup.enabled = false;
|
|
598
|
+
if (stored.announceToAgent === false)
|
|
599
|
+
startup.announceToAgent = false;
|
|
600
|
+
if (Object.keys(startup).length > 0)
|
|
601
|
+
applyPatch(startup);
|
|
602
|
+
const events = settingsCtx;
|
|
603
|
+
const off = events.events.on('settings/updated', (ns, next) => {
|
|
604
|
+
if (ns !== 'tty' || typeof next !== 'object' || next === null)
|
|
605
|
+
return;
|
|
606
|
+
applyPatch(next);
|
|
607
|
+
});
|
|
608
|
+
return () => {
|
|
609
|
+
off();
|
|
610
|
+
settingsScope = undefined;
|
|
611
|
+
};
|
|
612
|
+
}, 'dsh-tty: settings');
|
|
613
|
+
});
|
|
614
|
+
// agent 工具集(P1):tty_list / tty_capture / tty_send。
|
|
615
|
+
// 信任模型:与 bash 工具同权(agent 本就能执行任意命令),不额外加确认层;
|
|
616
|
+
// agent 对终端的操作会实时出现在浏览器面板里(同一 PTY),天然可被用户观察。
|
|
617
|
+
// inject: ['tools'] 声明后(见上方),ctx.get('tools') 才能解析到服务。
|
|
618
|
+
const toolsHost = ctx.get('tools');
|
|
619
|
+
if (toolsHost !== undefined) {
|
|
620
|
+
ctx.effect(() => {
|
|
621
|
+
const tools = toolsHost;
|
|
622
|
+
const tailLines = (session, lines) => {
|
|
623
|
+
const parts = session.buffer.split('\n');
|
|
624
|
+
return parts.slice(-(lines + 1)).join('\n').replace(/^\n+/, '');
|
|
625
|
+
};
|
|
626
|
+
const disposers = [];
|
|
627
|
+
disposers.push(tools.register(defineTool({
|
|
628
|
+
name: 'tty_list',
|
|
629
|
+
description: '列出当前活跃的终端面板会话(sid / pid / cwd / 创建与最后活动时间)。用户开了终端面板后,用 tty_capture 读取某个 sid 的终端输出,用 tty_send 向该终端发送按键。',
|
|
630
|
+
parameters: {},
|
|
631
|
+
output: {
|
|
632
|
+
schema: {
|
|
633
|
+
type: 'object',
|
|
634
|
+
additionalProperties: false,
|
|
635
|
+
properties: {
|
|
636
|
+
sessions: {
|
|
637
|
+
type: 'array',
|
|
638
|
+
required: true,
|
|
639
|
+
items: {
|
|
640
|
+
type: 'object',
|
|
641
|
+
additionalProperties: false,
|
|
642
|
+
properties: {
|
|
643
|
+
sid: { type: 'string', required: true },
|
|
644
|
+
pid: { type: 'number', required: true },
|
|
645
|
+
cwd: { type: 'string', required: true },
|
|
646
|
+
startedAt: { type: 'number', required: true },
|
|
647
|
+
lastOutputAt: { type: 'number', required: true },
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
render: (_args, value) => {
|
|
654
|
+
const sessions = value?.sessions ?? [];
|
|
655
|
+
const text = sessions.length === 0
|
|
656
|
+
? '当前没有活跃的终端面板会话(请引导用户先打开终端面板,或用户尚未打开)'
|
|
657
|
+
: '终端面板会话:' + sessions.map((s) => `\n- sid=${s.sid} pid=${s.pid} cwd=${s.cwd} (启动于 ${new Date(s.startedAt).toLocaleString()})`).join('');
|
|
658
|
+
return [{ type: 'text', text }];
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
async execute() {
|
|
662
|
+
return { sessions: sessions.list() };
|
|
663
|
+
},
|
|
664
|
+
})));
|
|
665
|
+
disposers.push(tools.register(defineTool({
|
|
666
|
+
name: 'tty_capture',
|
|
667
|
+
description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出(默认尾部 60 行,最多 500 行)。适合查看用户终端里正在运行的 dev server / watch / 构建输出。',
|
|
668
|
+
parameters: {
|
|
669
|
+
sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
|
|
670
|
+
lines: { type: 'number', description: '读取尾部行数(1~500,默认 60)' },
|
|
671
|
+
},
|
|
672
|
+
output: {
|
|
673
|
+
schema: {
|
|
674
|
+
type: 'object',
|
|
675
|
+
additionalProperties: false,
|
|
676
|
+
properties: {
|
|
677
|
+
sid: { type: 'string', required: true },
|
|
678
|
+
tail: { type: 'string', required: true },
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
render: (_args, value) => {
|
|
682
|
+
const v = value;
|
|
683
|
+
return [{ type: 'text', text: `终端会话 ${v.sid ?? '?'} 尾部输出:\n\n${v.tail ?? ''}` }];
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
async execute(args) {
|
|
687
|
+
const input = args;
|
|
688
|
+
if (typeof input.sid !== 'string' || input.sid === '')
|
|
689
|
+
throw new Error('sid 必须是非空字符串');
|
|
690
|
+
const lines = Math.max(1, Math.min(500, typeof input.lines === 'number' && Number.isInteger(input.lines) && input.lines >= 1 ? input.lines : 60));
|
|
691
|
+
const session = sessions.get(input.sid);
|
|
692
|
+
if (session === undefined || session.closed)
|
|
693
|
+
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
694
|
+
return { sid: input.sid, tail: tailLines(session, lines) };
|
|
695
|
+
},
|
|
696
|
+
})));
|
|
697
|
+
disposers.push(tools.register(defineTool({
|
|
698
|
+
name: 'tty_send',
|
|
699
|
+
description: '向某个终端面板会话(tty_list 提供 sid)的 PTY 发送按键/文本(命令以 \\n 结尾)。适合给用户终端里运行的程序发交互输入(如 dev server 的 q 键、menu 选择、回答提示)。操作会实时显示在用户的终端面板里。',
|
|
700
|
+
parameters: {
|
|
701
|
+
sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
|
|
702
|
+
data: { type: 'string', required: true, description: '要发送的文本(含换行则直接发送命令)' },
|
|
703
|
+
},
|
|
704
|
+
output: {
|
|
705
|
+
schema: {
|
|
706
|
+
type: 'object',
|
|
707
|
+
additionalProperties: false,
|
|
708
|
+
properties: {
|
|
709
|
+
ok: { type: 'boolean', required: true },
|
|
710
|
+
sent: { type: 'number', required: true },
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
render: (_args, value) => {
|
|
714
|
+
const v = value;
|
|
715
|
+
return [{ type: 'text', text: `已向终端会话发送 ${v.sent ?? 0} 个字符` }];
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
async execute(args) {
|
|
719
|
+
const input = args;
|
|
720
|
+
if (typeof input.sid !== 'string' || input.sid === '')
|
|
721
|
+
throw new Error('sid 必须是非空字符串');
|
|
722
|
+
if (typeof input.data !== 'string' || input.data === '')
|
|
723
|
+
throw new Error('data 必须是非空字符串');
|
|
724
|
+
const session = sessions.get(input.sid);
|
|
725
|
+
if (session === undefined || session.closed)
|
|
726
|
+
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
727
|
+
await session.handle.write(input.data);
|
|
728
|
+
return { ok: true, sent: input.data.length };
|
|
729
|
+
},
|
|
730
|
+
})));
|
|
731
|
+
stateRef.toolsRegistered = true;
|
|
732
|
+
console.log('[dsh-tty] agent tools registered (tty_list, tty_capture, tty_send)');
|
|
733
|
+
return () => {
|
|
734
|
+
stateRef.toolsRegistered = false;
|
|
735
|
+
for (const dispose of disposers) {
|
|
736
|
+
try {
|
|
737
|
+
dispose();
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
/* 工具已注销 */
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
}, 'dsh-tty: agent tools');
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
console.log('[dsh-tty] tools service unavailable; agent tools skipped');
|
|
748
|
+
}
|
|
749
|
+
// 向 agent 公告终端面板能力
|
|
750
|
+
if (config?.announceToAgent !== false) {
|
|
751
|
+
ctx.inject(['systemPrompt'], (promptCtx) => {
|
|
752
|
+
promptCtx.effect(() => {
|
|
753
|
+
const systemPrompt = promptCtx.systemPrompt;
|
|
754
|
+
return systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
|
|
755
|
+
}, 'dsh-tty: announcement');
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
// 插件卸载时回收全部会话
|
|
759
|
+
ctx.effect(() => {
|
|
760
|
+
return () => {
|
|
761
|
+
void sessions.disposeAll();
|
|
762
|
+
};
|
|
763
|
+
}, 'dsh-tty: session cleanup');
|
|
764
|
+
console.log(`[dsh-tty] mounted (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue})`);
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
export const { name, inject, apply } = plugin;
|
|
768
|
+
//# sourceMappingURL=index.js.map
|