@hyzyn/dsh-tty 0.20.1 → 0.21.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/lib/index.d.ts CHANGED
@@ -67,6 +67,7 @@
67
67
  * 子进程),必须 best-effort:失败降级为对顶层 shell 直接 SIGKILL。
68
68
  */
69
69
  import type { Context } from '@deepseek-ai/cordis';
70
+ import z from '@deepseek-ai/schemastery';
70
71
  import { StringDecoder } from 'node:string_decoder';
71
72
  import WebSocket from 'ws';
72
73
  import xtermHeadless from '@xterm/headless';
@@ -125,6 +126,15 @@ export interface SftpLimits {
125
126
  /** 一次批量/拖拽上传的文件数上限。默认 1000。 */
126
127
  maxUploadFiles: number;
127
128
  }
129
+ /**
130
+ * 运行时 Config schema——DSH ≥0.1.7 起同时就是本插件的 settings 存储。
131
+ *
132
+ * 全部字段都标 `.volatile()`:它们都是「插件配置 → 终端面板」卡片可改项,而
133
+ * `settings.update(entryId, patch)` 只接受 volatile 路径;loader 对 volatile-only
134
+ * 变更原地更新引用并发 `loader/volatile-update`,不重挂插件——插件订阅后走
135
+ * `applyPatch` 热应用(见 @hyzyn/dsh-kit 的 settingsEntryScope)。
136
+ */
137
+ export declare const Config: z;
128
138
  /**
129
139
  * 本地 PTY 顶层 shell 的 best-effort 强杀(D48)。
130
140
  *
@@ -225,7 +235,7 @@ interface ReqLike {
225
235
  interface SocketLike {
226
236
  destroy(): void;
227
237
  }
228
- /** 可热更新的运行时配置(settings/updated 动态应用)。 */
238
+ /** 可热更新的运行时配置(loader 的 volatile 更新事件动态应用)。 */
229
239
  declare class LiveConfig {
230
240
  shell: string;
231
241
  term: string;
@@ -497,6 +507,8 @@ export declare class TtyServer {
497
507
  private readonly pendingTmux;
498
508
  /** 已接线的面板连接(sessions 帧广播用;比 wss.clients 更贴合「面板」语义,单测也可驱动)。 */
499
509
  private readonly panels;
510
+ /** 会话 → 它所属连接的 sid 映射(kill 兜底结案时要从本地表里摘除)。 */
511
+ private readonly sessionLocals;
500
512
  /** WS 闸门(插件禁用时关闭):拒绝新升级 + 断开存量连接。 */
501
513
  private wsGateOpen;
502
514
  /** 服务器状态条总开关(配置热生效;关闭时停掉全部采集,重开按订阅恢复)。 */
@@ -637,6 +649,14 @@ export declare class TtyServer {
637
649
  private handleStatsFrame;
638
650
  /** 会话退出事实 → exit 帧(恰好一次;本地 PTY 与 SSH 共用)。 */
639
651
  private watchDone;
652
+ /**
653
+ * 会话终局的**唯一出口**:退役 + 清理 + 给所有绑定连接发 exit 帧(恰好一次)。
654
+ *
655
+ * `outcome` 正常来自 PTY 句柄的 done;显式 kill 的兜底(KILL_EXIT_FALLBACK_MS)
656
+ * 也走这里,带 code=null / signal=SIGKILL。exit 广播到所有绑定连接(跨窗口共享),
657
+ * 各客户端按自己的 sid 收址。
658
+ */
659
+ private finishSession;
640
660
  /** 输出下行 + 基于 ws.bufferedAmount 的背压(暂停/恢复 PassThrough)。 */
641
661
  private attachOutput;
642
662
  /** 立即冲刷待发的合并输出(exit/kill 前调用,保证 exit 帧永远在最后一帧 data 之后)。 */
package/lib/index.js CHANGED
@@ -12,7 +12,7 @@ import WebSocket, { WebSocketServer } from 'ws';
12
12
  // found),必须默认导入后取 Terminal;类型用 InstanceType 别名保持同名可用
13
13
  import xtermHeadless from '@xterm/headless';
14
14
  const HeadlessTerminal = xtermHeadless.Terminal;
15
- import { definePlugin, dshHome as resolveDshHome } from '@hyzyn/dsh-kit';
15
+ import { definePlugin, dshHome as resolveDshHome, plainConfig, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
16
16
  import { defineTool } from '@deepseek-ai/dsh-tools';
17
17
  import { spawnSsh, sshTarget, expandHome, setCredentialResolver } from './ssh.js';
18
18
  import { probeSsh } from './probe.js';
@@ -55,30 +55,37 @@ const TUNNEL_SCHEMA = z.object({
55
55
  localTargetPort: z.natural().max(65535).default(0),
56
56
  enabled: z.boolean().default(true),
57
57
  });
58
- /** 与「插件配置 → 终端面板」卡片表单对齐的 schema。 */
59
- const TTY_SETTINGS_SCHEMA = z.object({
60
- enabled: z.boolean().default(true),
61
- announceToAgent: z.boolean().default(true),
62
- maxSessions: z.natural().max(16).default(4),
63
- shell: z.string().default(''),
64
- term: z.string().default('xterm-256color'),
65
- colorTerm: z.string().default('truecolor'),
66
- cwd: z.string().default(''),
67
- reconnectGraceSec: z.natural().max(3600).default(120),
68
- sshHosts: z.array(SSH_HOST_SCHEMA).default([]),
69
- hostKeys: z.array(HOST_KEY_SCHEMA).default([]),
70
- tunnels: z.array(TUNNEL_SCHEMA).default([]),
71
- shellIntegration: z.boolean().default(true),
72
- sftpStyle: z.union([z.const('dialog'), z.const('dual')]).default('dialog'),
73
- persistence: z.union([z.const('off'), z.const('tmux')]).default('off'),
74
- endOnPageClose: z.boolean().default(false),
75
- statsEnabled: z.boolean().default(true),
58
+ /**
59
+ * 运行时 Config schema——DSH ≥0.1.7 起同时就是本插件的 settings 存储。
60
+ *
61
+ * 全部字段都标 `.volatile()`:它们都是「插件配置 → 终端面板」卡片可改项,而
62
+ * `settings.update(entryId, patch)` 只接受 volatile 路径;loader 对 volatile-only
63
+ * 变更原地更新引用并发 `loader/volatile-update`,不重挂插件——插件订阅后走
64
+ * `applyPatch` 热应用(见 @hyzyn/dsh-kit 的 settingsEntryScope)。
65
+ */
66
+ export const Config = z.object({
67
+ enabled: z.boolean().default(true).volatile(),
68
+ announceToAgent: z.boolean().default(true).volatile(),
69
+ maxSessions: z.natural().max(16).default(4).volatile(),
70
+ shell: z.string().default('').volatile(),
71
+ term: z.string().default('xterm-256color').volatile(),
72
+ colorTerm: z.string().default('truecolor').volatile(),
73
+ cwd: z.string().default('').volatile(),
74
+ reconnectGraceSec: z.natural().max(3600).default(120).volatile(),
75
+ sshHosts: z.array(SSH_HOST_SCHEMA).default([]).volatile(),
76
+ hostKeys: z.array(HOST_KEY_SCHEMA).default([]).volatile(),
77
+ tunnels: z.array(TUNNEL_SCHEMA).default([]).volatile(),
78
+ shellIntegration: z.boolean().default(true).volatile(),
79
+ sftpStyle: z.union([z.const('dialog'), z.const('dual')]).default('dialog').volatile(),
80
+ persistence: z.union([z.const('off'), z.const('tmux')]).default('off').volatile(),
81
+ endOnPageClose: z.boolean().default(false).volatile(),
82
+ statsEnabled: z.boolean().default(true).volatile(),
76
83
  sftpLimits: z.object({
77
84
  maxDownloadMb: z.natural().max(1024 * 1024).default(1024),
78
85
  maxUploadMb: z.natural().max(1024 * 1024).default(2048),
79
86
  maxUploadFiles: z.natural().max(100000).default(1000),
80
- }).default({ maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 }),
81
- persistSessions: z.array(z.object({ tmuxName: z.string() })).default([]),
87
+ }).default({ maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 }).volatile(),
88
+ persistSessions: z.array(z.object({ tmuxName: z.string() })).default([]).volatile(),
82
89
  });
83
90
  /* ------------------------------------------------------------------ *
84
91
  * 常量
@@ -90,6 +97,13 @@ const DEFAULT_RECONNECT_GRACE_SEC = 120;
90
97
  /** 下行背压阈值(ws.bufferedAmount 字节)。 */
91
98
  const BACKPRESSURE_HIGH = 512 * 1024;
92
99
  const BACKPRESSURE_LOW = 128 * 1024;
100
+ /**
101
+ * 显式 kill 后的 exit 帧兜底(毫秒)。PTY 句柄的 `done` 承诺「恰好 resolve 一次」,
102
+ * 但个别平台/后端上 forceKill 之后它迟迟不兑现(实测 rc.1 的 subprocess-local 在
103
+ * Linux 上会卡住),而「发过 kill 就必须收到一条 exit 帧」是前端契约(B1/B3 用例
104
+ * 钉的就是它)。超时即按 SIGKILL 结案;done 真回来时靠 session.exitSent 幂等忽略。
105
+ */
106
+ const KILL_EXIT_FALLBACK_MS = 2000;
93
107
  const SID_RE = /^[A-Za-z0-9_-]{1,64}$/;
94
108
  /** 自定义命令标签(0.14.0)的长度上限:单条命令,防误传超长脚本。 */
95
109
  const COMMAND_MAX = 2000;
@@ -179,7 +193,7 @@ function sanitizeTermValue(value, fallback) {
179
193
  const trimmed = value.trim();
180
194
  return TERM_RE.test(trimmed) ? trimmed : fallback;
181
195
  }
182
- /** 可热更新的运行时配置(settings/updated 动态应用)。 */
196
+ /** 可热更新的运行时配置(loader 的 volatile 更新事件动态应用)。 */
183
197
  class LiveConfig {
184
198
  shell;
185
199
  term;
@@ -1147,6 +1161,8 @@ export class TtyServer {
1147
1161
  pendingTmux = new Map();
1148
1162
  /** 已接线的面板连接(sessions 帧广播用;比 wss.clients 更贴合「面板」语义,单测也可驱动)。 */
1149
1163
  panels = new Set();
1164
+ /** 会话 → 它所属连接的 sid 映射(kill 兜底结案时要从本地表里摘除)。 */
1165
+ sessionLocals = new WeakMap();
1150
1166
  /** WS 闸门(插件禁用时关闭):拒绝新升级 + 断开存量连接。 */
1151
1167
  wsGateOpen = true;
1152
1168
  /** 服务器状态条总开关(配置热生效;关闭时停掉全部采集,重开按订阅恢复)。 */
@@ -1745,6 +1761,11 @@ export class TtyServer {
1745
1761
  /* 已退出 */
1746
1762
  }
1747
1763
  void forceKill(session.handle);
1764
+ // 兜底:handle.done 不兑现时也要按「用户已 kill」结案(见 KILL_EXIT_FALLBACK_MS)。
1765
+ const timer = setTimeout(() => {
1766
+ this.finishSession(session, { exitCode: null, signal: 'SIGKILL' });
1767
+ }, KILL_EXIT_FALLBACK_MS);
1768
+ timer.unref?.();
1748
1769
  }
1749
1770
  /** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。
1750
1771
  * 构造参数在 createHeadlessScreen(D57:scrollback 不能是 0),这里只做委托。 */
@@ -2128,34 +2149,42 @@ export class TtyServer {
2128
2149
  }
2129
2150
  /** 会话退出事实 → exit 帧(恰好一次;本地 PTY 与 SSH 共用)。 */
2130
2151
  watchDone(session, local) {
2152
+ this.sessionLocals.set(session, local);
2131
2153
  session.handle.done.then((outcome) => {
2132
- // kill 主动关闭时会话可能已被移出 local,用 exitSent 保证 exit 帧恰好一次;
2133
- // 发送走 session.ws 动态取值——attach 换连接后 exit 也能跟着新连接走
2134
- if (session.exitSent === true)
2135
- return;
2136
- session.exitSent = true;
2137
- session.closed = true;
2138
- session.statsSubs.clear();
2139
- this.stopStats(session);
2140
- local.delete(session.id);
2141
- this.sessions.remove(session.id);
2142
- if (session.kind === 'ssh' && session.tmuxName !== null)
2143
- this.trackPersist(session.tmuxName, false);
2144
- clearScreenWatchdog(session.screenHeartbeat);
2145
- try {
2146
- session.screen?.dispose();
2147
- }
2148
- catch {
2149
- /* 已释放 */
2150
- }
2151
- this.flushPendingOutput(session); // exit 前冲掉合并窗口里的尾巴,保序
2152
- // exit 广播到所有绑定连接(跨窗口共享),各客户端按自己的 sid 收址
2153
- for (const client of session.clients.values()) {
2154
- send(client.ws, { t: 'exit', sid: client.sid, code: outcome.exitCode, signal: outcome.signal });
2155
- }
2156
- session.clients.clear();
2154
+ this.finishSession(session, outcome);
2157
2155
  }).catch(() => { });
2158
2156
  }
2157
+ /**
2158
+ * 会话终局的**唯一出口**:退役 + 清理 + 给所有绑定连接发 exit 帧(恰好一次)。
2159
+ *
2160
+ * `outcome` 正常来自 PTY 句柄的 done;显式 kill 的兜底(KILL_EXIT_FALLBACK_MS)
2161
+ * 也走这里,带 code=null / signal=SIGKILL。exit 广播到所有绑定连接(跨窗口共享),
2162
+ * 各客户端按自己的 sid 收址。
2163
+ */
2164
+ finishSession(session, outcome) {
2165
+ if (session.exitSent === true)
2166
+ return;
2167
+ session.exitSent = true;
2168
+ session.closed = true;
2169
+ session.statsSubs.clear();
2170
+ this.stopStats(session);
2171
+ this.sessionLocals.get(session)?.delete(session.id);
2172
+ this.sessions.remove(session.id);
2173
+ if (session.kind === 'ssh' && session.tmuxName !== null)
2174
+ this.trackPersist(session.tmuxName, false);
2175
+ clearScreenWatchdog(session.screenHeartbeat);
2176
+ try {
2177
+ session.screen?.dispose();
2178
+ }
2179
+ catch {
2180
+ /* 已释放 */
2181
+ }
2182
+ this.flushPendingOutput(session); // exit 前冲掉合并窗口里的尾巴,保序
2183
+ for (const client of session.clients.values()) {
2184
+ send(client.ws, { t: 'exit', sid: client.sid, code: outcome.exitCode, signal: outcome.signal });
2185
+ }
2186
+ session.clients.clear();
2187
+ }
2159
2188
  /** 输出下行 + 基于 ws.bufferedAmount 的背压(暂停/恢复 PassThrough)。 */
2160
2189
  attachOutput(session) {
2161
2190
  const output = session.handle.output;
@@ -2556,7 +2585,9 @@ const plugin = definePlugin({
2556
2585
  // 声明 inject:tools 服务只有声明式 inject 才能解析(动态 ctx.inject/ctx.get
2557
2586
  // 均拿不到,实测 mcp-client 同款模式),声明后 ctx.get('tools') 才能取到。
2558
2587
  inject: ['tools'],
2559
- apply(ctx, config) {
2588
+ apply(ctx, rawConfig) {
2589
+ // volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
2590
+ const config = plainConfig((rawConfig ?? {}));
2560
2591
  if (config?.enabled === false)
2561
2592
  return;
2562
2593
  const live = new LiveConfig({
@@ -2648,7 +2679,7 @@ const plugin = definePlugin({
2648
2679
  */
2649
2680
  platform: process.platform,
2650
2681
  });
2651
- /** 规范化并应用一份配置补丁(settings/updated 事件与 HTTP POST 共用;幂等)。 */
2682
+ /** 规范化并应用一份配置补丁(volatile 更新事件与 HTTP POST 共用;幂等)。 */
2652
2683
  const applyPatch = (section) => {
2653
2684
  live.apply({
2654
2685
  shell: typeof section.shell === 'string' ? section.shell : undefined,
@@ -2868,8 +2899,8 @@ const plugin = definePlugin({
2868
2899
  const scope = settingsScope;
2869
2900
  if (scope !== undefined) {
2870
2901
  try {
2871
- // 官方持久化通道:写入 settings 命名空间(dsh-settings-file),
2872
- // 成功后触发 settings/updated → applyPatch 热应用
2902
+ // 官方持久化通道:settings.update(entryId) 写进本插件 entry 的 profile
2903
+ // patch(volatile 字段),成功后 loader 发 volatile 更新 → applyPatch 热应用
2873
2904
  await scope.update(patch);
2874
2905
  }
2875
2906
  catch (error) {
@@ -3334,11 +3365,16 @@ const plugin = definePlugin({
3334
3365
  };
3335
3366
  }, 'dsh-tty: web routes');
3336
3367
  });
3337
- // settings 命名空间:注册 + 启动合并持久化值 + settings/updated 热应用
3368
+ // settings:DSH ≥0.1.7 起存储就是本插件 entry 的 Config(导出为 `Config`,可写字段
3369
+ // 标了 volatile)。启动合并一次持久化值,之后由 loader 的 volatile 更新事件热应用。
3338
3370
  ctx.inject(['settings'], (settingsCtx) => {
3339
3371
  settingsCtx.effect(() => {
3340
- const settings = settingsCtx.settings;
3341
- const scope = settings.register('tty', TTY_SETTINGS_SCHEMA);
3372
+ const scope = settingsEntryScope(settingsCtx, 'tty');
3373
+ // 服务形态不符(老宿主 / 最小宿主)时不装 scope:卡片仍可看快照,只是不持久化。
3374
+ if (scope === undefined)
3375
+ return () => { };
3376
+ // 卡片是自定义页(plugins.row.config),别再让 DSH 为本 entry 自动生成一份。
3377
+ const offAutoPage = suppressAutoSettingsPage(settingsCtx, ctx);
3342
3378
  settingsScope = scope;
3343
3379
  // 启动合并:字符串字段非空才覆盖;maxSessions/布尔用「非默认值才覆盖」启发式
3344
3380
  //(schema 默认值会混入 resolved,无法区分「显式保存的 4」与「从未保存」)。
@@ -3383,14 +3419,10 @@ const plugin = definePlugin({
3383
3419
  startup.tunnels = storedTunnels;
3384
3420
  if (Object.keys(startup).length > 0)
3385
3421
  applyPatch(startup);
3386
- const events = settingsCtx;
3387
- const off = events.events.on('settings/updated', (ns, next) => {
3388
- if (ns !== 'tty' || typeof next !== 'object' || next === null)
3389
- return;
3390
- applyPatch(next);
3391
- });
3422
+ const off = scope.onChanged(() => applyPatch(scope.get()));
3392
3423
  return () => {
3393
3424
  off();
3425
+ offAutoPage();
3394
3426
  settingsScope = undefined;
3395
3427
  };
3396
3428
  }, 'dsh-tty: settings');
@@ -3851,6 +3883,7 @@ const plugin = definePlugin({
3851
3883
  bookName: { type: 'string', required: true },
3852
3884
  state: { type: 'string', required: true },
3853
3885
  error: { type: 'string' },
3886
+ fatal: { type: 'boolean', required: true },
3854
3887
  connections: { type: 'number', required: true },
3855
3888
  totalConnections: { type: 'number', required: true },
3856
3889
  },
@@ -3864,7 +3897,10 @@ const plugin = definePlugin({
3864
3897
  if (tunnels.length === 0)
3865
3898
  return [{ type: 'text', text: '当前没有配置端口转发隧道(插件配置 → 终端面板 卡片可添加)' }];
3866
3899
  const text = '端口转发隧道:' + tunnels.map((t) => {
3867
- const tail = t.error !== null && t.error !== undefined ? `(错误: ${t.error})` : t.lastForwardError !== null && t.lastForwardError !== undefined ? `(最近转发失败: ${t.lastForwardError})` : `(连接 ${String(t.connections)})`;
3900
+ // fatal 单独措辞(D58):这类故障不会自愈,不说清就会一直等「正在连」
3901
+ const tail = t.error !== null && t.error !== undefined
3902
+ ? (t.fatal === true ? `(错误: ${t.error} —— 不会自动重试,需修配置)` : `(错误: ${t.error})`)
3903
+ : t.lastForwardError !== null && t.lastForwardError !== undefined ? `(最近转发失败: ${t.lastForwardError})` : `(连接 ${String(t.connections)})`;
3868
3904
  return `\n- ${t.name} [${t.direction}] ${t.rule} — ${t.state}${tail}`;
3869
3905
  }).join('');
3870
3906
  return [{ type: 'text', text }];
@@ -3885,6 +3921,7 @@ const plugin = definePlugin({
3885
3921
  rule: t.rule,
3886
3922
  state: t.state,
3887
3923
  ...(t.error === null || t.error === undefined ? {} : { error: t.error }),
3924
+ fatal: t.fatal,
3888
3925
  connections: t.connections,
3889
3926
  totalConnections: t.totalConnections,
3890
3927
  })),