@hyzyn/dsh-tty 0.21.0 → 0.22.0-rc.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.
@@ -87,6 +87,12 @@ function openSession(port) {
87
87
  /** ConPTY 里按 Enter 是 \r(顺带补 \n 兼容两种翻译),cmd 才会执行。 */
88
88
  const ENTER = '\r\n'
89
89
 
90
+ /**
91
+ * 注册进来的 agent 工具定义(W6 要**直接调 `tty_send` 的工具路径**——D74 修的就是它)。
92
+ * 原先 stub 的 `register` 只吞掉返回值,等于这里没有 agent 工具入口。
93
+ */
94
+ const TOOLS = new Map()
95
+
90
96
  async function run() {
91
97
  const app = new Context()
92
98
  const wsFiber = app.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
@@ -97,7 +103,7 @@ async function run() {
97
103
  // DSH ≥0.1.7 的 settings 服务(SettingsForms):describe / update / configure。
98
104
  // 本用例不测配置持久化,空 describe 即可(插件的 settings effect 拿到空值)。
99
105
  ctx.provide('settings', { describe: () => [], update: async () => {}, configure: () => () => {} })
100
- ctx.provide('tools', { register: () => () => {} })
106
+ ctx.provide('tools', { register: (definition) => { TOOLS.set(definition.name, definition); return () => {} } })
101
107
  },
102
108
  })
103
109
  await stubFiber.await()
@@ -171,6 +177,99 @@ async function run() {
171
177
  fail('W5 kill 后可重新 spawn', error.message)
172
178
  }
173
179
  try { s2.client.close() } catch { /* 已关闭 */ }
180
+
181
+ // W6:agent 侧 `tty_send` 发**裸 LF** 也能提交命令(D74,2026-09-27 真机报告)
182
+ //
183
+ // 为什么必须在真机上钉这条:报告的第 1 条是 conhost 的**真实行为**(Enter 是 CR,
184
+ // 裸 LF 只把光标下移、不提交命令行)。本地单测只能在 macOS/Linux 上 mock
185
+ // `process.platform` 验「插件有没有归一化」,验不了「归一化之后 conhost 真的执行了」。
186
+ // 这里用 `%OS%` 展开做判据,理由同 W3:回显里只有 `%OS%` 字面量。
187
+ console.log('\n[W6] tty_send 的 \\n 归一化(真实 conhost:裸 LF 不提交命令)')
188
+ let agentSid = null
189
+ try {
190
+ const open = TOOLS.get('tty_open')
191
+ const send = TOOLS.get('tty_send')
192
+ const capture = TOOLS.get('tty_capture')
193
+ if (open === undefined || send === undefined || capture === undefined) {
194
+ throw new Error('agent 工具没注册进 stub(注册到的:' + [...TOOLS.keys()].join(', ') + ')')
195
+ }
196
+ const opened = await open.execute({ cwd: process.cwd(), cols: 100, rows: 30 })
197
+ agentSid = opened.sid
198
+ await send.execute({ sid: agentSid, data: 'echo IT_LF_%OS%\n' })
199
+ const deadline = Date.now() + 15000
200
+ let tail = ''
201
+ for (;;) {
202
+ tail = String((await capture.execute({ sid: agentSid, lines: 60 })).tail ?? '')
203
+ if (/IT_LF_Windows_NT/.test(tail)) break
204
+ if (Date.now() > deadline) throw new Error('15s 内没等到 %OS% 展开;尾部:' + tail.slice(-300))
205
+ await sleep(200)
206
+ }
207
+ pass('W6 tty_send 的裸 LF 在 ConPTY 上提交并执行了命令(D74:win32 归一化成 CRLF)')
208
+ } catch (error) {
209
+ fail('W6 tty_send 的裸 LF 提交命令(D74)', error.message)
210
+ }
211
+ if (agentSid !== null) {
212
+ try {
213
+ await TOOLS.get('tty_close').execute({ sid: agentSid })
214
+ } catch {
215
+ /* 已关闭 */
216
+ }
217
+ }
218
+
219
+ // W7:进程退出后转「只读保留」——输出还能读回来(D77,issue #4 的正题)
220
+ //
221
+ // 为什么必须在真机上钉:条目链路在真实 ConPTY 上才完整——`command` 型会话跑完,
222
+ // ConPTY 侧进程退出、句柄收尾、宿主把会话**留在表里**而不是摘掉;本地单测用的是
223
+ // 假 PTY,验不到「真机上退出之后 tty_capture 还读得到输出」。
224
+ console.log('\n[W7] 退出后的只读保留(D77)')
225
+ let retainedSid = null
226
+ try {
227
+ const open = TOOLS.get('tty_open')
228
+ const list = TOOLS.get('tty_list')
229
+ const capture = TOOLS.get('tty_capture')
230
+ const send = TOOLS.get('tty_send')
231
+ if (open === undefined || list === undefined || capture === undefined || send === undefined) {
232
+ throw new Error('agent 工具没注册进 stub(注册到的:' + [...TOOLS.keys()].join(', ') + ')')
233
+ }
234
+ // `command` 直接跑一条会结束的命令(正是上报现场那种用法)
235
+ const opened = await open.execute({ cwd: process.cwd(), command: 'echo IT_RETAIN_%OS%' })
236
+ retainedSid = opened.sid
237
+ const deadline = Date.now() + 20000
238
+ let entry = null
239
+ for (;;) {
240
+ entry = (await list.execute({})).sessions.find((x) => x.sid === retainedSid) ?? null
241
+ if (entry !== null && entry.exited === true) break
242
+ if (Date.now() > deadline) throw new Error('20s 内没等到 exited:true;最近一条:' + JSON.stringify(entry))
243
+ await sleep(200)
244
+ }
245
+ pass('W7a 命令退出后会话仍是只读保留态(exited:true)')
246
+ const read = await capture.execute({ sid: retainedSid, lines: 60 })
247
+ if (read.exited === true && /IT_RETAIN_Windows_NT/.test(String(read.tail ?? ''))) {
248
+ pass('W7b 退出之后 tty_capture 仍读得到退出前的输出')
249
+ } else {
250
+ fail('W7b 退出之后 tty_capture 仍读得到输出(D77)', JSON.stringify(read).slice(0, 200))
251
+ }
252
+ let refused = false
253
+ try {
254
+ await send.execute({ sid: retainedSid, data: 'echo nope\n' })
255
+ } catch {
256
+ refused = true
257
+ }
258
+ if (refused) pass('W7c 对只读保留态 tty_send 明确拒写')
259
+ else fail('W7c 对只读保留态 tty_send 明确拒写', '居然写进去了')
260
+ } catch (error) {
261
+ fail('W7 退出后的只读保留(D77)', error.message)
262
+ }
263
+ if (retainedSid !== null) {
264
+ try {
265
+ await TOOLS.get('tty_close').execute({ sid: retainedSid })
266
+ const gone = !(await TOOLS.get('tty_list').execute({})).sessions.some((x) => x.sid === retainedSid)
267
+ if (gone) pass('W7d tty_close 释放保留态(关掉后从清单消失)')
268
+ else fail('W7d tty_close 释放保留态', '仍在清单里')
269
+ } catch (error) {
270
+ fail('W7d tty_close 释放保留态', error.message)
271
+ }
272
+ }
174
273
  }
175
274
 
176
275
  await run()
@@ -178,4 +277,27 @@ const failed = RESULTS.filter((row) => row[0] === 'FAIL')
178
277
  console.log('\n==== Windows 冒烟:' + String(RESULTS.length - failed.length) + '/' + String(RESULTS.length) + ' PASS ====')
179
278
  for (const row of failed) console.error(' ✘ ' + row[1] + (row[2] ? ' — ' + row[2] : ''))
180
279
  clearTimeout(watchdog)
280
+ /**
281
+ * 先把两条流排空再退(D62)。
282
+ *
283
+ * `process.exit()` 会**丢掉管道里还没 flush 的写**(Node 文档原话:尽快退出,不等
284
+ * 未完成的异步操作)。CI 上 stdout 正是管道,缓冲 64 KB:实测「写 300 KB 后直接
285
+ * exit」只活下来 65 536 字节。所以 2026-09-25 那次 CI 只留下
286
+ * `##[error]Process completed with exit code 1.` —— 五条断言全是 PASS、没有 ✘ 行、
287
+ * 也没有末尾的汇总行,失败原因整段被吞掉,只能靠拆发布记录反推"大概是哪一步"。
288
+ *
289
+ * 空串写入的回调在**前面那些写都落盘之后**才触发,等于一个 flush 屏障;拿到它再退,
290
+ * 输出就一定完整。仍然用 exit 而不是 `process.exitCode`(D121:主体结束后可能有
291
+ * 周期句柄漏着,不显式退就会挂死,而看门狗已经 clear 了)。
292
+ */
293
+ await new Promise((resolve) => {
294
+ process.stdout.write('', () => {
295
+ resolve()
296
+ })
297
+ })
298
+ await new Promise((resolve) => {
299
+ process.stderr.write('', () => {
300
+ resolve()
301
+ })
302
+ })
181
303
  process.exit(failed.length === 0 ? 0 : 1)