@hyzyn/dsh-tty 0.20.0 → 0.20.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyzyn/dsh-tty",
3
- "version": "0.20.0",
3
+ "version": "0.20.2",
4
4
  "description": "DSH Web GUI 的终端面板插件:侧边栏「终端」大弹窗,xterm.js + PTY 全交互终端(路线1),ssh2 原生 SSH 远程连接(方案 C),tmux 会话持久化(0.10.0),连接栏扩展点 ttyConnbar(0.13.0)、终端服务 ttyTerminal(0.14.0 open / 0.15.0 mount 就地嵌入)、面板内挂载位 ttyPanel(0.16.0 右侧 dock)、服务器状态条(0.17.0:按标签可见性采端主机 CPU / 内存 / 磁盘 / 在线时长 / TCP / 网速 / 温度,SSH 走同连接的非 PTY exec channel、本地走 os/proc,best-effort、不碰 PTY 流)、连接密码存入官方凭据存储(0.18.0:字段里只留 env: 引用、引用名按资源身份派生、候选来自凭据存储 ∪ 连接簿;设置卡片的连接编辑与连接对话框共用同一套表单)、Windows 宿主支持(0.18.1:默认 shell = %COMSPEC%、不做 POSIX 包装层、PowerShell 走 -NoLogo/-Command;shell 集成 / tmux / 状态条三项在 Windows 本地不适用,README 有完整边界)。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -33,28 +33,30 @@
33
33
  "./package.json": "./package.json"
34
34
  },
35
35
  "dsh": {
36
+ "manifestVersion": 1,
36
37
  "bundle": {
37
38
  "patch": "./cordis.patch.yml"
38
39
  },
39
40
  "engines": {
40
- "dsh": ">=0.1.2-rc.1"
41
+ "dsh": ">=0.1.7-rc.1"
41
42
  },
42
43
  "client": {
43
44
  "platform": "web"
44
45
  }
45
46
  },
46
47
  "dependencies": {
47
- "@deepseek-ai/schemastery": "^3.18.2",
48
- "@hyzyn/dsh-kit": "0.4.1",
48
+ "@deepseek-ai/schemastery": "^3.18.4",
49
+ "@hyzyn/dsh-kit": "0.4.2",
49
50
  "@xterm/headless": "^5.5.0",
50
51
  "ssh2": "^1.17.0",
51
52
  "ws": "^8.18.0"
52
53
  },
53
54
  "peerDependencies": {
54
- "@deepseek-ai/dsh-tools": "^0.1.2-rc.1"
55
+ "@deepseek-ai/dsh": "^0.1.7-rc.1",
56
+ "@deepseek-ai/dsh-tools": "^0.1.7-rc.1"
55
57
  },
56
58
  "devDependencies": {
57
- "@deepseek-ai/cordis": "^4.0.2",
59
+ "@deepseek-ai/cordis": "^4.0.4",
58
60
  "@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1",
59
61
  "@deepseek-ai/dsh-subprocess": "0.1.2-rc.1",
60
62
  "@deepseek-ai/dsh-subprocess-local": "0.1.2-rc.1",
@@ -90,6 +92,11 @@
90
92
  "publishConfig": {
91
93
  "access": "public"
92
94
  },
95
+ "peerDependenciesMeta": {
96
+ "@deepseek-ai/dsh": {
97
+ "optional": true
98
+ }
99
+ },
93
100
  "scripts": {
94
101
  "build": "tsc -p tsconfig.json && node scripts/build-client.mjs",
95
102
  "typecheck": "tsc --noEmit && node ../../scripts/client-lint.mjs",
@@ -136,8 +136,15 @@ async function run() {
136
136
  const app = new Context()
137
137
  const wsFiber = app.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
138
138
  const subFiber = app.plugin(LocalSubprocessRuntime)
139
- // 最小 settings 服务 stub:让插件的 settings 注入回调触发,并可手动派发
140
- // settings/updated 事件(与 dsh-settings 的 dispatch 方式一致)来测配置热生效。
139
+ // 最小 settings 服务 stub(DSH ≥0.1.7 的 SettingsForms:describe / update /
140
+ // configure):让插件的 settings 注入回调触发;配置热生效经 loader 的
141
+ // volatile-update 事件派发(见 emitSettingsUpdated)。
142
+ let settingsValue = {}
143
+ /** 派发 loader 的 volatile-update:真实宿主在 settings.update 之后由 loader 发出。 */
144
+ const emitVolatile = () => {
145
+ const args = ['loader/volatile-update', [], {}, 'test']
146
+ for (const cb of app.events.dispatch('emit', args)) cb(...args)
147
+ }
141
148
  const toolDefs = []
142
149
  /** 输出契约违约(§B33:返回值与声明的 output.schema 不符,真实宿主会直接判工具错)。 */
143
150
  const outputViolations = []
@@ -145,7 +152,15 @@ async function run() {
145
152
  const stubFiber = app.plugin({
146
153
  name: 'settings-stub',
147
154
  apply: (ctx) => {
148
- ctx.provide('settings', { register: () => ({ get: () => ({}), update: async () => {} }) })
155
+ ctx.provide('settings', {
156
+ describe: () => [{ ns: 'tty', value: settingsValue }],
157
+ update: async (ns, patch) => {
158
+ if (ns !== 'tty') throw new Error('未知的 entry: ' + ns)
159
+ settingsValue = { ...settingsValue, ...patch }
160
+ emitVolatile()
161
+ },
162
+ configure: () => () => {},
163
+ })
149
164
  ctx.provide('tools', {
150
165
  register: (definition) => {
151
166
  // 输出契约看门狗(§B33):把 execute 的返回值按**声明的 output.schema**
@@ -202,9 +217,10 @@ async function run() {
202
217
  },
203
218
  })
204
219
  await stubFiber.await()
220
+ /** 模拟「外部改了本 entry 的配置」:更新 stub 值再派发 loader 的 volatile-update。 */
205
221
  const emitSettingsUpdated = (ns, next) => {
206
- const args = ['settings/updated', ns, next, {}, 'test']
207
- for (const cb of app.events.dispatch('emit', args)) cb(...args)
222
+ if (ns === 'tty') settingsValue = { ...next }
223
+ emitVolatile()
208
224
  }
209
225
  const pluginFiber = app.plugin({ name, inject, apply }, { maxSessions: 2, term: 'xterm-256color', colorTerm: 'truecolor' })
210
226
  await wsFiber.await()
@@ -278,7 +294,18 @@ async function run() {
278
294
  if (/sid 已存在/.test(s.state.errors[0])) pass('B3 同 sid 二次 spawn 被拒')
279
295
  else fail('B3 同 sid 二次 spawn 被拒', s.state.errors[0])
280
296
  s.client.send(JSON.stringify({ t: 'kill', sid: 'tab-a' }))
281
- await s.waitFor(() => s.state.exited !== null, 10000, 'exit')
297
+ try {
298
+ await s.waitFor(() => s.state.exited !== null, 10000, 'exit')
299
+ } catch (error) {
300
+ // 诊断(CI 上 Linux 专属复现):区分「kill 没生效(服务端仍列着它)」与
301
+ // 「会话已消失、只是 exit 帧没发(PTY 句柄的 done 未兑现)」。
302
+ s.client.send(JSON.stringify({ t: 'sessions' }))
303
+ await sleep(500)
304
+ const sessionsFrame = [...s.state.frames].reverse().find((f) => f.t === 'sessions')
305
+ console.error(' [diag] 收到的帧类型=' + JSON.stringify(s.state.frames.map((f) => f.t)))
306
+ console.error(' [diag] 服务端会话清单=' + JSON.stringify(sessionsFrame?.list ?? null))
307
+ throw error
308
+ }
282
309
  s.client.close()
283
310
  }
284
311
 
@@ -1187,9 +1214,11 @@ async function run() {
1187
1214
  execFile(file, args, { timeout: 5000 }, (error, stdout, stderr) => resolve({ error, stdout: String(stdout), stderr: String(stderr) }))
1188
1215
  })
1189
1216
  })
1217
+ let tmuxListError = null
1190
1218
  const tmuxList = async () => {
1191
1219
  const out = await execFileP('tmux', ['-L', 'dsh-tty', 'list-sessions', '-F', '#{session_name}'])
1192
- return out.error !== null && out.error !== undefined ? [] : out.stdout.trim().split('\n').filter(Boolean)
1220
+ tmuxListError = out.error === null || out.error === undefined ? null : (out.stderr || String(out.error))
1221
+ return tmuxListError !== null ? [] : out.stdout.trim().split('\n').filter(Boolean)
1193
1222
  }
1194
1223
  const post = async (body) => {
1195
1224
  const res = await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
@@ -1274,8 +1303,18 @@ async function run() {
1274
1303
  await post({ reconnectGraceSec: 1 })
1275
1304
  const w1 = openSession(port)
1276
1305
  await w1.open()
1306
+ const cfgBefore = await (await fetch(`http://127.0.0.1:${port}/api/dsh-tty/config`)).json()
1277
1307
  w1.client.send(JSON.stringify({ t: 'spawn', sid: 'b26a', cols: 80, rows: 24, persist: true, persistName: 'b26resume' }))
1278
1308
  await w1.waitFor(() => w1.state.ready, 10000, 'b26a ready')
1309
+ {
1310
+ const readyFrame = w1.state.frames.find((f) => f.t === 'ready' && f.sid === 'b26a')
1311
+ const listedAfterSpawn = await tmuxList()
1312
+ console.error(' [diag] persistence=' + String(cfgBefore?.config?.persistence)
1313
+ + ' ready.persist=' + String(readyFrame?.persist)
1314
+ + ' tmuxAfterSpawn=' + JSON.stringify(listedAfterSpawn)
1315
+ + ' TMPDIR=' + JSON.stringify(process.env.TMPDIR ?? null)
1316
+ + ' tmuxListError=' + JSON.stringify(tmuxListError))
1317
+ }
1279
1318
  w1.client.send(JSON.stringify({ t: 'input', sid: 'b26a', d: 'printf "B26MARK-%s\\n" resume\n' }))
1280
1319
  await w1.waitFor(() => /B26MARK-resume/.test(w1.state.text), 10000, '标记输出')
1281
1320
  // 异常断开 → 孤儿 → 保活 1s + 回收器扫描(≤10s)只杀 PTY;tmux 会话应存活
@@ -12,6 +12,8 @@
12
12
  * node scripts/preview.mjs --list # 列出场景
13
13
  * node scripts/preview.mjs --build # 顺带重建 client.js(默认不重建,产物落后直接报错)
14
14
  * node scripts/preview.mjs --theme=light # 浅色主题
15
+ * node scripts/preview.mjs --width=560 # 压窄视口走查窄容器排版(默认 1680)
16
+ * PREVIEW_CHROME_ARGS=--no-sandbox node scripts/preview.mjs # 受限沙箱 / CI 里 Chrome 起不来时
15
17
  *
16
18
  * 需要本机有 Chrome/Chromium(默认找 playwright 缓存的 Chrome for Testing,
17
19
  * 也可用 CHROME_PATH 指定)。
@@ -37,6 +39,18 @@ const wantBuild = flags.has('--build')
37
39
  /** 输出子目录:--out=shots-light 之类,便于并排比较明暗主题。 */
38
40
  const outName = (argv.find((a) => a.startsWith('--out=')) || '--out=shots').split('=')[1]
39
41
  const shotsDir = join(previewDir, outName)
42
+ /**
43
+ * 视口宽度(默认 1680)。真实宿主里这张卡片常挂在窄容器里(插件管理页 / 侧栏),
44
+ * 1680px 的宽视口会把「一行塞不塞得下」这类排版问题盖掉——走查窄排版时用
45
+ * `--width=560` 之类显式压窄。
46
+ */
47
+ const viewportWidth = Number((argv.find((a) => a.startsWith('--width=')) || '--width=1680').split('=')[1]) || 1680
48
+ /**
49
+ * 额外 Chrome 参数(空格分隔)。受限环境(文件沙箱 / CI 容器)里 Chrome 自己的
50
+ * sandbox 起不来,CDP 会一直卡在 `Page.enable` 直到超时——此时用
51
+ * `PREVIEW_CHROME_ARGS=--no-sandbox`。刻意不做默认:那会削弱所有调用方的隔离。
52
+ */
53
+ const extraChromeArgs = (process.env.PREVIEW_CHROME_ARGS ?? '').split(' ').filter((a) => a !== '')
40
54
 
41
55
  /* ------------------------------- 场景清单 ------------------------------- */
42
56
 
@@ -331,6 +345,7 @@ async function launchChrome() {
331
345
  '--disable-webgl',
332
346
  '--force-color-profile=srgb',
333
347
  '--font-render-hinting=none',
348
+ ...extraChromeArgs,
334
349
  'about:blank',
335
350
  ], { stdio: ['ignore', 'ignore', 'pipe'] })
336
351
 
@@ -475,7 +490,7 @@ async function main() {
475
490
  await ensureVendor()
476
491
  log('启动 headless Chrome(' + theme + ' 主题)')
477
492
  const { child, cdp, userDataDir } = await launchChrome()
478
- await cdp.send('Emulation.setDeviceMetricsOverride', { width: 1680, height: 1050, deviceScaleFactor: 2, mobile: false })
493
+ await cdp.send('Emulation.setDeviceMetricsOverride', { width: viewportWidth, height: 1050, deviceScaleFactor: 2, mobile: false })
479
494
  let failed = 0
480
495
  try {
481
496
  for (const name of wanted) {
@@ -24,7 +24,9 @@ async function run() {
24
24
  const stubFiber = app.plugin({
25
25
  name: 'settings-stub',
26
26
  apply: (ctx) => {
27
- ctx.provide('settings', { register: () => ({ get: () => ({}), update: async () => {} }) })
27
+ // DSH ≥0.1.7 的 settings 服务(SettingsForms):describe / update / configure。
28
+ // 本用例不测配置持久化,空 describe 即可(插件的 settings effect 拿到空值)。
29
+ ctx.provide('settings', { describe: () => [], update: async () => {}, configure: () => () => {} })
28
30
  ctx.provide('tools', { register: () => () => {} })
29
31
  // 与真实契约同形(section/context 返回注销器):插件热应用时会调用重建
30
32
  ctx.provide('systemPrompt', {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * 复现:`@xterm/headless` 在「`scrollback: 0`(= D57 修复前 dsh-tty 虚拟屏的构造参数)
3
+ * + 输出与 resize 交错」下抛出未捕获 TypeError,**杀掉整个 Node 宿主进程**。
4
+ *
5
+ * TypeError: Cannot set properties of undefined (setting 'isWrapped')
6
+ * at E.lineFeed (.../@xterm/headless/lib-headless/xterm-headless.js:1:28221)
7
+ * at c.parse … at n._action … at n._innerWrite … at Timeout._onTimeout
8
+ *
9
+ * 与线上 `~/.dsh/dsh-safe/last-failure-web.log` 里那次宿主崩溃的堆栈逐帧一致。
10
+ *
11
+ * 运行(`packages/tty` 下):
12
+ * node scripts/screen-crash-repro.mjs # 用本包解析到的 @xterm/headless
13
+ * node scripts/screen-crash-repro.mjs <另一个 headless.js> # 换版本对比(如 6.0.0,仍然崩)
14
+ *
15
+ * 期望输出:`exit=1` + 上面那段堆栈(进程被未捕获异常打死)。
16
+ * 若打印 `no uncaught exception`,说明该版本已修 —— 这时该复核 SCREEN_SCROLLBACK 这条护栏。
17
+ *
18
+ * 这是**排查用**的一次性脚本;日常回归看 `test/screen-crash.test.ts`(D57)。
19
+ */
20
+ import { createRequire } from 'node:module'
21
+ import { existsSync } from 'node:fs'
22
+
23
+ const require = createRequire(import.meta.url)
24
+ // 默认用本包依赖解析出来的 @xterm/headless(`packages/tty` 下运行即为本包版本)
25
+ const defaultXterm = require.resolve('@xterm/headless')
26
+ const xtermPath = process.argv[2] ?? defaultXterm
27
+ if (!existsSync(xtermPath)) {
28
+ console.error('xterm headless 不存在:' + xtermPath)
29
+ process.exit(2)
30
+ }
31
+
32
+ const { Terminal } = require(xtermPath)
33
+
34
+ // 刻意不装 uncaughtException 处理器 —— 与修复前的 dsh-tty / dsh 宿主现状一致,
35
+ // 异常在 setTimeout 回调里逃逸,Node 直接打死进程(正是线上那次的表现)。
36
+ const terminal = new Terminal({ cols: 60, rows: 3, scrollback: 0, allowProposedApi: true })
37
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
38
+
39
+ // D57 的 10 步最小复现序列:输出 → 设滚动区 → 反复 resize(触发 reflow)→ 末尾一个 '\n'
40
+ const ops = [
41
+ ['write', 'A'.repeat(300)],
42
+ ['write', '\x1b[3;9r'],
43
+ ['write', '\r\n'],
44
+ ['resize', 70, 40],
45
+ ['write', '\r\n'],
46
+ ['resize', 33, 5],
47
+ ['resize', 118, 8],
48
+ ['write', 'A'.repeat(300)],
49
+ ['resize', 21, 11],
50
+ ['write', '\n'], // ← 崩在这一步:lineFeed 里 lines.get() 返回 undefined
51
+ ]
52
+
53
+ for (const op of ops) {
54
+ if (op[0] === 'write') terminal.write(op[1])
55
+ else terminal.resize(op[1], op[2])
56
+ await tick()
57
+ }
58
+ for (let i = 0; i < 5; i++) await tick()
59
+
60
+ // 只有没崩才会走到这里
61
+ console.log('no uncaught exception')
62
+ process.exit(0)
@@ -18,7 +18,9 @@ async function run() {
18
18
  const stubFiber = app.plugin({
19
19
  name: 'settings-stub',
20
20
  apply: (ctx) => {
21
- ctx.provide('settings', { register: () => ({ get: () => ({}), update: async () => {} }) })
21
+ // DSH ≥0.1.7 的 settings 服务(SettingsForms):describe / update / configure。
22
+ // 本用例不测配置持久化,空 describe 即可(插件的 settings effect 拿到空值)。
23
+ ctx.provide('settings', { describe: () => [], update: async () => {}, configure: () => () => {} })
22
24
  ctx.provide('tools', { register: () => () => {} })
23
25
  // 与真实契约同形(section/context 返回注销器):插件热应用时会调用重建
24
26
  ctx.provide('systemPrompt', {
@@ -94,7 +94,9 @@ async function run() {
94
94
  const stubFiber = app.plugin({
95
95
  name: 'windows-smoke-stub',
96
96
  apply: (ctx) => {
97
- ctx.provide('settings', { register: () => ({ get: () => ({}), update: async () => {} }) })
97
+ // DSH ≥0.1.7 的 settings 服务(SettingsForms):describe / update / configure。
98
+ // 本用例不测配置持久化,空 describe 即可(插件的 settings effect 拿到空值)。
99
+ ctx.provide('settings', { describe: () => [], update: async () => {}, configure: () => () => {} })
98
100
  ctx.provide('tools', { register: () => () => {} })
99
101
  },
100
102
  })