@hyzyn/dsh-tty 0.31.0 → 0.32.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.
@@ -0,0 +1,411 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @hyzyn/dsh-tty — PowerShell 家族真机对照探针(Windows PowerShell 5.1 ↔ PowerShell 7)。
4
+ *
5
+ * 为什么单独一个探针:`windows-smoke.mjs` 的 W8 只钉两条断言(桩生效 = 收到首个 A;
6
+ * 一条命令发全 B/D 且真的执行了),而且它在有 pwsh 的机器上**只跑 pwsh**。也就是说
7
+ * 「5.1 与 7 行为差异」这一整类事实在真机上从来没有被对照过。本探针把支持面在两个
8
+ * shell 上跑**同一套**用例并排输出:桩生效 / cwd 跟随(含含空格的路径)/ running 三态 /
9
+ * 退出码(命令级与会话级)/ `tty_expect` 早停与**回显剔除** / `capture{last}` 的输出窗口 /
10
+ * 命令型会话(`tty_run`)/ `&&` 的语法差异。
11
+ *
12
+ * 判据分两档:
13
+ * - PASS / FAIL:该行为**应当**如此,不对就是缺陷(退出码非 0);
14
+ * - INFO:测量值与原始证据——给「要不要修、怎么修」提供数字。INFO 不影响退出码。
15
+ *
16
+ * 两条刻意的设计(否则会自欺):
17
+ * 1. **pattern 不能字面出现在命令行里**的用例,用 `('A'+'_B')` 拼出来 —— 否则命中的
18
+ * 可能只是命令回显(这正是 D75 要防的假绿);
19
+ * 2. `tty_expect` 的耗时**有下界**:命令里带 `Start-Sleep -Seconds 2`,命中早于 1.5s
20
+ * 就说明命中的是回显、不是输出。
21
+ *
22
+ * 用法(Windows):
23
+ * node packages/tty/scripts/pwsh-probe.mjs # 自动找 5.1 与 7,两个都跑
24
+ * node packages/tty/scripts/pwsh-probe.mjs --shell <path> --label <名字>
25
+ * node packages/tty/scripts/pwsh-probe.mjs --n 20 # P6 的采样次数(默认 20)
26
+ *
27
+ * 非 Windows 平台优雅退出(退出码 0):本探针的全部价值都在真实 ConPTY 上。
28
+ */
29
+ import { existsSync } from 'node:fs'
30
+ import { Context } from '@deepseek-ai/cordis'
31
+ import WebServerRuntime from '@deepseek-ai/dsh-host-webserver'
32
+ import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
33
+ import { name, inject, apply } from '../lib/index.js'
34
+
35
+ if (process.platform !== 'win32') {
36
+ console.log(`[pwsh-probe] 跳过:当前平台 ${process.platform}——本探针只在 Windows 上跑(真实 ConPTY + 真实 PowerShell)`)
37
+ process.exit(0)
38
+ }
39
+
40
+ const argv = process.argv.slice(2)
41
+ function argOf(flag) {
42
+ const i = argv.indexOf(flag)
43
+ return i === -1 ? undefined : argv[i + 1]
44
+ }
45
+ const N = Math.max(1, Number(argOf('--n') ?? 20))
46
+ const ONLY_SHELL = argOf('--shell')
47
+ const ONLY_LABEL = argOf('--label')
48
+
49
+ const CANDIDATES = [
50
+ { label: 'PowerShell 7 (pwsh)', shell: 'C:/Program Files/PowerShell/7/pwsh.exe' },
51
+ { label: 'Windows PowerShell 5.1', shell: 'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe' },
52
+ ]
53
+
54
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
55
+
56
+ const ROWS = []
57
+ function row(shell, item, status, detail) {
58
+ ROWS.push({ shell, item, status, detail })
59
+ const mark = status === 'PASS' ? ' ✔ PASS ' : status === 'FAIL' ? ' ✘ FAIL ' : ' ℹ INFO '
60
+ const line = mark + '[' + shell + '] ' + item + (detail === undefined ? '' : ' — ' + detail)
61
+ if (status === 'FAIL') console.error(line)
62
+ else console.log(line)
63
+ }
64
+ function pass(shell, item, detail) { row(shell, item, 'PASS', detail) }
65
+ function fail(shell, item, detail) { row(shell, item, 'FAIL', detail) }
66
+ function info(shell, item, detail) { row(shell, item, 'INFO', detail) }
67
+
68
+ /** 注册进来的 agent 工具(与 windows-smoke.mjs 同形:直接走 `execute`,即 agent 的真实路径)。 */
69
+ const TOOLS = new Map()
70
+ function tool(name) {
71
+ const t = TOOLS.get(name)
72
+ if (t === undefined) throw new Error(`agent 工具没注册:${name}(注册到的:${[...TOOLS.keys()].join(', ')})`)
73
+ return t
74
+ }
75
+
76
+ async function mount(shell) {
77
+ TOOLS.clear()
78
+ const app = new Context()
79
+ const wsFiber = app.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
80
+ const subFiber = app.plugin(LocalSubprocessRuntime)
81
+ const stubFiber = app.plugin({
82
+ name: 'pwsh-probe-stub',
83
+ apply: (ctx) => {
84
+ ctx.provide('settings', { describe: () => [], update: async () => {}, configure: () => () => {} })
85
+ ctx.provide('tools', { register: (definition) => { TOOLS.set(definition.name, definition); return () => {} } })
86
+ },
87
+ })
88
+ await stubFiber.await()
89
+ const pluginFiber = app.plugin({ name, inject, apply }, { shell, term: 'xterm-256color', colorTerm: 'truecolor', maxSessions: 4 })
90
+ await wsFiber.await()
91
+ await subFiber.await()
92
+ await pluginFiber.await()
93
+ await sleep(400) // 等 ctx.inject(['webServer']) 回调注册路由
94
+ return app
95
+ }
96
+
97
+ async function entryOf(sid) {
98
+ const listed = await tool('tty_list').execute({})
99
+ return listed.sessions.find((s) => s.sid === sid) ?? null
100
+ }
101
+
102
+ /** 等 tty_list 上某个字段满足条件;超时抛错(带上最后一次快照)。 */
103
+ async function waitEntry(sid, pred, timeoutMs, label) {
104
+ const deadline = Date.now() + timeoutMs
105
+ let last = null
106
+ for (;;) {
107
+ last = await entryOf(sid)
108
+ if (last !== null && pred(last)) return last
109
+ if (Date.now() > deadline) throw new Error(`等不到「${label}」(${timeoutMs}ms);最后一次快照:${JSON.stringify(last)}`)
110
+ await sleep(100)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 发一条命令并等它结束(判据用 `lastExitAt` 前进,不依赖具体输出)。
116
+ *
117
+ * **只用 tty_list 轮询**——`tty_capture` 会推进已读水位线,而下面有几条用例正是要
118
+ * 「命令结束之后、还没读过任何输出」这一刻的现场(D72/D75 的闸门都看这个水位线)。
119
+ */
120
+ async function runCommand(sid, command, timeoutMs = 20000) {
121
+ const before = (await entryOf(sid))?.lastExitAt ?? 0
122
+ await tool('tty_send').execute({ sid, data: command + '\n' })
123
+ const deadline = Date.now() + timeoutMs
124
+ for (;;) {
125
+ const snap = await entryOf(sid)
126
+ if (snap !== null && snap.running === false && (snap.lastExitAt ?? 0) > before) return snap
127
+ if (Date.now() > deadline) throw new Error(`命令「${command}」在 ${timeoutMs}ms 内没结束;快照:${JSON.stringify(snap)}`)
128
+ await sleep(80)
129
+ }
130
+ }
131
+
132
+ /** 读「上一条已完成命令」(命令在途时等它落地)。 */
133
+ async function lastCommand(sid, timeoutMs = 10000) {
134
+ const deadline = Date.now() + timeoutMs
135
+ for (;;) {
136
+ try {
137
+ const read = await tool('tty_capture').execute({ sid, last: true })
138
+ if (read.inProgress !== true) return read
139
+ } catch (error) {
140
+ // 「暂无上一条命令」在命令刚发出去时是正常的(B 标记还没到),继续等
141
+ if (!/暂无|没有「上一条命令」/.test(String(error?.message ?? ''))) throw error
142
+ }
143
+ if (Date.now() > deadline) return { sid, tail: '', source: 'last', inProgress: true }
144
+ await sleep(80)
145
+ }
146
+ }
147
+
148
+ /** 人读起来短一点的原始尾部。 */
149
+ function short(text, n = 200) {
150
+ return JSON.stringify(String(text ?? '').replace(/\s+/g, ' ').slice(-n))
151
+ }
152
+
153
+ async function probeShell({ label, shell }) {
154
+ console.log(`\n===== ${label} — ${shell} =====`)
155
+ await mount(shell)
156
+
157
+ // ---------- P0 版本(证明探针真的跑在这个 shell 上)----------
158
+ try {
159
+ const run = await tool('tty_run').execute({ command: '$PSVersionTable.PSVersion.ToString() + " / " + $PSVersionTable.PSEdition' })
160
+ const text = String(run.tail ?? '').split('\n').map((x) => x.trim()).filter((x) => /^\d+\.\d+/.test(x)).pop() ?? ''
161
+ pass(label, 'P0 版本自述', `${text}(exitCode=${String(run.exitCode ?? '?')})`)
162
+ } catch (error) {
163
+ fail(label, 'P0 版本自述', error.message)
164
+ }
165
+
166
+ // ---------- P1 shell 集成生效(sawMark 可信 → running 是三态里的布尔)----------
167
+ const opened = await tool('tty_open').execute({ cwd: process.cwd(), cols: 100, rows: 30 })
168
+ const sid = opened.sid
169
+ try {
170
+ const snap = await waitEntry(sid, (e) => e.running === false, 25000, 'shell 集成标记(running 变布尔)')
171
+ pass(label, 'P1 桩载入 + shell 集成生效', `running=${String(snap.running)}(省略 = 没见过 OSC 133 标记)`)
172
+ } catch (error) {
173
+ fail(label, 'P1 桩载入 + shell 集成生效', error.message)
174
+ }
175
+
176
+ // ---------- P2 cwd 跟随(OSC 7 上报;上报的是 URI 形态,分隔符是正斜杠)----------
177
+ try {
178
+ await runCommand(sid, 'Set-Location C:\\Windows')
179
+ const snap = await entryOf(sid)
180
+ // OSC 7 是 file:///C:/Windows 这种 URI 形态 → 解出来是 `C:/Windows`(正斜杠)。
181
+ // 两种分隔符都接受:这里判的是「cwd 有没有跟着走」,不是分隔符风格。
182
+ if (snap !== null && /^[A-Za-z]:[\\/]Windows$/.test(String(snap.cwd))) pass(label, 'P2a cwd 跟随 Set-Location', String(snap.cwd))
183
+ else fail(label, 'P2a cwd 跟随 Set-Location', `cwd=${JSON.stringify(snap?.cwd)}`)
184
+ } catch (error) {
185
+ fail(label, 'P2a cwd 跟随 Set-Location', error.message)
186
+ }
187
+ try {
188
+ await runCommand(sid, "Set-Location 'C:\\Program Files'")
189
+ const snap = await entryOf(sid)
190
+ if (snap !== null && /Program Files$/i.test(String(snap.cwd))) pass(label, 'P2b cwd 跟随(路径含空格,验 OSC 7 的 %20 解码)', String(snap.cwd))
191
+ else fail(label, 'P2b cwd 跟随(路径含空格)', `cwd=${JSON.stringify(snap?.cwd)}`)
192
+ } catch (error) {
193
+ fail(label, 'P2b cwd 跟随(路径含空格)', error.message)
194
+ }
195
+
196
+ // ---------- P3 running 三态 ----------
197
+ try {
198
+ await tool('tty_send').execute({ sid, data: 'Start-Sleep -Seconds 3\n' })
199
+ let sawTrue = false
200
+ const deadline = Date.now() + 2500
201
+ while (Date.now() < deadline) {
202
+ const snap = await entryOf(sid)
203
+ if (snap?.running === true) { sawTrue = true; break }
204
+ await sleep(100)
205
+ }
206
+ if (!sawTrue) fail(label, 'P3a 命令在跑时 running=true', '2.5s 内没看到 running:true')
207
+ else pass(label, 'P3a 命令在跑时 running=true')
208
+ await waitEntry(sid, (e) => e.running === false, 15000, '命令结束后 running=false')
209
+ pass(label, 'P3b 命令结束后 running=false')
210
+ } catch (error) {
211
+ fail(label, 'P3 running 三态', error.message)
212
+ }
213
+
214
+ // ---------- P4 退出码 ----------
215
+ try {
216
+ await runCommand(sid, 'cmd /c exit 7')
217
+ const read = await lastCommand(sid)
218
+ if (read.exitCode === 7) pass(label, 'P4a 命令级退出码(原生命令 $LASTEXITCODE=7)', 'capture{last}.exitCode=7')
219
+ else fail(label, 'P4a 命令级退出码', `capture{last}.exitCode=${JSON.stringify(read.exitCode)} tail=${short(read.tail, 120)}`)
220
+ } catch (error) {
221
+ fail(label, 'P4a 命令级退出码', error.message)
222
+ }
223
+ try {
224
+ const opened2 = await tool('tty_open').execute({ cwd: process.cwd(), cols: 80, rows: 24 })
225
+ await tool('tty_send').execute({ sid: opened2.sid, data: 'exit 3\n' })
226
+ const snap = await waitEntry(opened2.sid, (e) => e.exited === true, 15000, '会话退出')
227
+ if (snap.exitCode === 3) pass(label, 'P4b 会话级退出码(exit 3 → exited + exitCode)', `exitCode=${String(snap.exitCode)}`)
228
+ else fail(label, 'P4b 会话级退出码', `exited=${String(snap.exited)} exitCode=${JSON.stringify(snap.exitCode)}`)
229
+ await tool('tty_close').execute({ sid: opened2.sid }).catch(() => {})
230
+ } catch (error) {
231
+ fail(label, 'P4b 会话级退出码', error.message)
232
+ }
233
+
234
+ // ---------- P5 tty_expect:早停、回显剔除、以及回显到底落在哪一段边界里 ----------
235
+ // P5a:pattern **不字面出现**在命令行里(用拼接造出来)→ 只能命中真实输出。
236
+ try {
237
+ const opened3 = await tool('tty_open').execute({ cwd: process.cwd(), cols: 100, rows: 30 })
238
+ await tool('tty_send').execute({ sid: opened3.sid, data: "Start-Sleep -Seconds 2; echo ('PROBE'+'_READY_OK')\n" })
239
+ const started = Date.now()
240
+ const matched = await tool('tty_expect').execute({ sid: opened3.sid, pattern: 'PROBE_READY_OK', timeoutSec: 20 })
241
+ const elapsed = Date.now() - started
242
+ if (matched.matched === true && elapsed >= 1500 && elapsed < 12000) pass(label, 'P5a tty_expect 命中真实输出即返回', `${elapsed}ms,matchedFrom=${String(matched.matchedFrom ?? '?')}`)
243
+ else fail(label, 'P5a tty_expect 命中真实输出即返回', `matched=${String(matched.matched)} elapsed=${elapsed}ms matchedFrom=${String(matched.matchedFrom ?? '?')} text=${short(matched.text, 160)}`)
244
+ await tool('tty_close').execute({ sid: opened3.sid }).catch(() => {})
245
+ } catch (error) {
246
+ fail(label, 'P5a tty_expect 命中真实输出即返回', error.message)
247
+ }
248
+
249
+ // P5b:pattern **字面出现**在命令行里,而命令前面有 2s 的睡眠。
250
+ // 命中早于 1.5s = 命中的是回显(命令还没跑完)→ D75 要防的假绿。
251
+ try {
252
+ const opened4 = await tool('tty_open').execute({ cwd: process.cwd(), cols: 100, rows: 30 })
253
+ await tool('tty_send').execute({ sid: opened4.sid, data: 'Start-Sleep -Seconds 2; echo PROBE_ECHO_QQQ\n' })
254
+ const started = Date.now()
255
+ const matched = await tool('tty_expect').execute({ sid: opened4.sid, pattern: 'PROBE_ECHO_QQQ', timeoutSec: 20 })
256
+ const elapsed = Date.now() - started
257
+ if (matched.matched === true && elapsed >= 1500) pass(label, 'P5b 回显不算命中(命令在跑时)', `${elapsed}ms 命中真实输出,matchedFrom=${String(matched.matchedFrom ?? '?')}`)
258
+ else if (matched.matched === true) fail(label, 'P5b 回显不算命中(命令在跑时)', `${elapsed}ms 就命中了——命令还在 Start-Sleep,命中的只能是回显(D75 的剔除在 inCommand 时不启用)`)
259
+ else fail(label, 'P5b 回显不算命中(命令在跑时)', `matched=${String(matched.matched)} elapsed=${elapsed}ms text=${short(matched.text, 160)}`)
260
+ await sleep(1200) // 让这条命令真的跑完,别把它的 D 标记留给下一条用例
261
+ await tool('tty_close').execute({ sid: opened4.sid }).catch(() => {})
262
+ } catch (error) {
263
+ fail(label, 'P5b 回显不算命中(命令在跑时)', error.message)
264
+ }
265
+
266
+ // P5c:命令**只有回显、没有任何 stdout**(`| Out-Null`),pattern 只出现在命令行里。
267
+ // 命令结束后立刻注册 expect(中间不读任何输出,水位线不许推进):
268
+ // - 命中 → 命中的只能是回显(这是判据的核心:那里根本没有输出);
269
+ // - matchedFrom 顺带说明回显落在哪条路径上(last = B..D 窗口被回显污染)。
270
+ try {
271
+ const opened5 = await tool('tty_open').execute({ cwd: process.cwd(), cols: 100, rows: 30 })
272
+ await waitEntry(opened5.sid, (e) => e.running === false, 25000, 'P5c 会话就绪')
273
+ await runCommand(opened5.sid, 'echo PROBE_ECHO_ONLY_ZZZ | Out-Null')
274
+ const started = Date.now()
275
+ const matched = await tool('tty_expect').execute({ sid: opened5.sid, pattern: 'PROBE_ECHO_ONLY_ZZZ', timeoutSec: 2 })
276
+ const elapsed = Date.now() - started
277
+ const detail = `matched=${String(matched.matched)} matchedFrom=${String(matched.matchedFrom ?? '-')} ${elapsed}ms`
278
+ if (matched.matched === true) fail(label, 'P5c 只有回显、没有输出时不该命中', `${detail}(那条命令用 Out-Null 吞了输出,pattern 只存在于命令行里)`)
279
+ else info(label, 'P5c 只有回显、没有输出时不命中(D75 的 A..B 边界前提成立)', detail)
280
+ await tool('tty_close').execute({ sid: opened5.sid }).catch(() => {})
281
+ } catch (error) {
282
+ fail(label, 'P5c 只有回显、没有输出时不该命中', error.message)
283
+ }
284
+
285
+ // ---------- P6 capture{last} 的输出窗口(pattern 用拼接造,确保命中一定来自 stdout)----------
286
+ let immediateMiss = 0
287
+ let delayedMiss = 0
288
+ let bufferMiss = 0
289
+ let inProgress = 0
290
+ let echoInWindow = 0
291
+ for (let i = 0; i < N; i += 1) {
292
+ const mark = `PROBE_LAST_${String(i)}_END`
293
+ // 命令行本身:注意它**不含** mark(拼接形态),所以窗口里出现它 = 回显混进了 B..D 窗口
294
+ const commandText = `echo ('PROBE_LAST_${String(i)}' + '_END')`
295
+ try {
296
+ await runCommand(sid, commandText)
297
+ const now = await tool('tty_capture').execute({ sid, last: true })
298
+ if (now.inProgress === true) inProgress += 1
299
+ if (!String(now.tail ?? '').includes(mark)) immediateMiss += 1
300
+ if (String(now.tail ?? '').includes(`PROBE_LAST_${String(i)}'`)) echoInWindow += 1
301
+ await sleep(700)
302
+ const later = await tool('tty_capture').execute({ sid, last: true })
303
+ if (!String(later.tail ?? '').includes(mark)) delayedMiss += 1
304
+ const tail = await tool('tty_capture').execute({ sid, lines: 40 })
305
+ if (!String(tail.tail ?? '').includes(mark)) bufferMiss += 1
306
+ } catch (error) {
307
+ immediateMiss += 1
308
+ delayedMiss += 1
309
+ bufferMiss += 1
310
+ info(label, `P6 第 ${String(i + 1)} 轮异常`, error.message)
311
+ }
312
+ }
313
+ info(label, `P6 capture{last} 空窗率(N=${String(N)})`,
314
+ `立即读取空 ${String(immediateMiss)}/${String(N)};延迟 700ms 后取空 ${String(delayedMiss)}/${String(N)};同一时刻纯尾部读(lines)取空 ${String(bufferMiss)}/${String(N)};inProgress ${String(inProgress)}/${String(N)};窗口里混进命令行回显 ${String(echoInWindow)}/${String(N)}`)
315
+
316
+ // P6b/P6c:文档里那条「PowerShell 7 上 stdout 晚于 D、窗口可能为空」的限制,输出量与时序各变一档。
317
+ try {
318
+ await runCommand(sid, '1..2000 | ForEach-Object { "BIG_" + $_ }', 60000)
319
+ const read = await lastCommand(sid)
320
+ if (String(read.tail ?? '').includes('BIG_2000')) pass(label, 'P6b 大输出(2000 行)的窗口不空且保尾', `exitCode=${JSON.stringify(read.exitCode)} 末行在窗口里`)
321
+ else fail(label, 'P6b 大输出(2000 行)的窗口不空且保尾', `tail=${short(read.tail, 160)}`)
322
+ } catch (error) {
323
+ fail(label, 'P6b 大输出(2000 行)的窗口', error.message)
324
+ }
325
+ try {
326
+ await runCommand(sid, '1..20 | ForEach-Object { "TRICKLE_" + $_; Start-Sleep -Milliseconds 100 }', 60000)
327
+ const read = await lastCommand(sid)
328
+ if (String(read.tail ?? '').includes('TRICKLE_20')) pass(label, 'P6c 慢输出(2s 内分 20 次)的窗口不空', `exitCode=${JSON.stringify(read.exitCode)}`)
329
+ else fail(label, 'P6c 慢输出(2s 内分 20 次)的窗口不空', `tail=${short(read.tail, 160)}`)
330
+ } catch (error) {
331
+ fail(label, 'P6c 慢输出的窗口', error.message)
332
+ }
333
+
334
+ // ---------- P7 命令型会话(tty_run,走 -Command)----------
335
+ try {
336
+ const run = await tool('tty_run').execute({ command: 'echo RUN_$env:OS' })
337
+ if (run.exitCode === 0 && /RUN_Windows_NT/.test(String(run.tail ?? ''))) pass(label, 'P7a tty_run 成功路径(-Command,$env:OS 展开)', `exitCode=0,closed=${String(run.closed)}`)
338
+ else fail(label, 'P7a tty_run 成功路径', `exitCode=${JSON.stringify(run.exitCode)} tail=${short(run.tail, 160)}`)
339
+ } catch (error) {
340
+ fail(label, 'P7a tty_run 成功路径', error.message)
341
+ }
342
+ try {
343
+ const run = await tool('tty_run').execute({ command: 'exit 5' })
344
+ if (run.exitCode === 5) pass(label, 'P7b tty_run 退出码透传(exit 5)', 'exitCode=5')
345
+ else fail(label, 'P7b tty_run 退出码透传(exit 5)', `exitCode=${JSON.stringify(run.exitCode)} tail=${short(run.tail, 160)}`)
346
+ } catch (error) {
347
+ fail(label, 'P7b tty_run 退出码透传(exit 5)', error.message)
348
+ }
349
+ try {
350
+ // PowerShell 的 `-Command` 语义:末条命令非零时进程退出码塌成 1(不是那条命令的码)。
351
+ // 这不是插件的缺陷,但用户在 `tty_run` 里等到的就是它 —— 如实量一份。
352
+ const run = await tool('tty_run').execute({ command: 'cmd /c exit 5' })
353
+ info(label, 'P7c PowerShell -Command 的退出码塌陷(原生命令非零 → 1;要具体码值就让命令自己 exit)', `cmd /c exit 5 → exitCode=${JSON.stringify(run.exitCode)}`)
354
+ } catch (error) {
355
+ info(label, 'P7c PowerShell -Command 的退出码塌陷', error.message)
356
+ }
357
+
358
+ // ---------- P8 `&&`:PS 5.1 不认、PS 7 认(`commandShellHint` 的文案指着这条差异)----------
359
+ try {
360
+ await runCommand(sid, 'echo A && echo B')
361
+ const read = await lastCommand(sid)
362
+ const tail = short(read.tail, 200)
363
+ const hasB = /(^|\n)\s*B\s*(\n|$)/.test(String(read.tail ?? ''))
364
+ if (label.includes('5.1')) {
365
+ // 5.1 是解析错误:命令一条都没跑(输出里只有错误文本,没有 A / B)
366
+ info(label, 'P8 `&&` 在 5.1 上是解析错误(命令没执行)', `exitCode=${JSON.stringify(read.exitCode)} hasB=${String(hasB)} tail=${tail}`)
367
+ if (read.exitCode === 0) info(label, 'P8 附注:5.1 把解析错误报成 exitCode=0(成功)', 'agent 只看退出码会误判成功——输出文本里才是真相')
368
+ } else if (read.exitCode === 0 && hasB) {
369
+ pass(label, 'P8 `&&` 可用(PS 7 支持 &&)', `exitCode=0 tail=${tail}`)
370
+ } else {
371
+ fail(label, 'P8 `&&` 可用(PS 7)', `exitCode=${JSON.stringify(read.exitCode)} hasB=${String(hasB)} tail=${tail}`)
372
+ }
373
+ } catch (error) {
374
+ fail(label, 'P8 `&&` 语法差异', error.message)
375
+ }
376
+
377
+ // ---------- P8b 两版都不合法的一行(解析错误)该报什么退出码 ----------
378
+ try {
379
+ await runCommand(sid, 'echo (1')
380
+ const read = await lastCommand(sid)
381
+ info(label, 'P8b 解析错误的退出码(两版都不合法的 `echo (1`)', `exitCode=${JSON.stringify(read.exitCode)} tail=${short(read.tail, 160)}`)
382
+ } catch (error) {
383
+ info(label, 'P8b 解析错误的退出码', error.message)
384
+ }
385
+
386
+ // ---------- 清理 ----------
387
+ await tool('tty_close').execute({ sid }).catch(() => {})
388
+ }
389
+
390
+ const shells = ONLY_SHELL !== undefined
391
+ ? [{ label: ONLY_LABEL ?? ONLY_SHELL, shell: ONLY_SHELL }]
392
+ : CANDIDATES.filter((c) => { try { return existsSync(c.shell) } catch { return false } })
393
+
394
+ if (shells.length === 0) {
395
+ console.log('[pwsh-probe] 本机既没有 PowerShell 7 也没有 Windows PowerShell 5.1,无可测对象')
396
+ process.exit(0)
397
+ }
398
+
399
+ for (const s of shells) {
400
+ try {
401
+ await probeShell(s)
402
+ } catch (error) {
403
+ fail(s.label, '探针整体异常', error?.stack ?? String(error))
404
+ }
405
+ }
406
+
407
+ const failed = ROWS.filter((r) => r.status === 'FAIL')
408
+ const passed = ROWS.filter((r) => r.status === 'PASS')
409
+ console.log(`\n==== pwsh-probe:${String(passed.length)} PASS / ${String(failed.length)} FAIL / ${String(ROWS.length - passed.length - failed.length)} INFO(shell:${shells.map((s) => s.label).join(' + ')})====`)
410
+ for (const r of failed) console.error(` ✘ [${r.shell}] ${r.item} — ${String(r.detail ?? '')}`)
411
+ process.exit(failed.length === 0 ? 0 : 1)
@@ -15,6 +15,7 @@
15
15
  * 用法:pnpm --filter @hyzyn/dsh-tty windows-smoke
16
16
  * 退出码:0 = 全部 PASS 或跳过,1 = 任一 FAIL。
17
17
  */
18
+ import { existsSync } from 'node:fs'
18
19
  import { Context } from '@deepseek-ai/cordis'
19
20
  import WebServerRuntime from '@deepseek-ai/dsh-host-webserver'
20
21
  import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
@@ -85,6 +86,8 @@ process.on('exit', (code) => {
85
86
  const RESULTS = []
86
87
  function pass(label) { RESULTS.push(['PASS', label]); console.log(' ✔ PASS ' + label) }
87
88
  function fail(label, detail) { RESULTS.push(['FAIL', label, detail]); console.error(' ✘ FAIL ' + label + (detail ? ' — ' + detail : '')) }
89
+ /** SKIP 单列一档:**不许混进 PASS 计数**,否则「跳过」会伪装成 12/12(W8 在本机没有 PowerShell 时走这条)。 */
90
+ function skip(label) { RESULTS.push(['SKIP', label]); console.log(' ⏭ SKIP ' + label) }
88
91
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
89
92
 
90
93
  /** 简易 ws 会话客户端(与 integration.mjs 同形,只保留本脚本要用的部分)。 */
@@ -142,6 +145,22 @@ const ENTER = '\r\n'
142
145
  */
143
146
  const TOOLS = new Map()
144
147
 
148
+ /** 本机可用的 PowerShell(优先 PowerShell 7,再 Windows PowerShell 5.1);都没有则 null(W8 跳过)。 */
149
+ function resolvePowerShell() {
150
+ const candidates = [
151
+ 'C:/Program Files/PowerShell/7/pwsh.exe',
152
+ 'C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe',
153
+ ]
154
+ for (const candidate of candidates) {
155
+ try {
156
+ if (existsSync(candidate)) return candidate
157
+ } catch {
158
+ /* 权限等异常一律当「没有」 */
159
+ }
160
+ }
161
+ return null
162
+ }
163
+
145
164
  async function run() {
146
165
  const app = new Context()
147
166
  const wsFiber = app.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
@@ -341,6 +360,77 @@ async function run() {
341
360
  }
342
361
  }
343
362
  tracer.leave('W7 退出后的只读保留')
363
+
364
+ // W8:PowerShell 家族的 shell 集成(OSC 133/7)必须真的从 ConPTY 里穿出来
365
+ //
366
+ // 为什么在这一档而不只在单测里:单测只能断言「spawn 计划长什么样」,而这条链路真正
367
+ // 容易断的地方在 ConPTY —— 标记是 conhost **直通转发**的旁路(渲染路径之外),被吞掉
368
+ // 或晚到只有真机才看得出。另起一个 app 实例是为了改 shell 配置:W1–W7 必须保持真实的
369
+ // `%COMSPEC%` 链路(它们钉的正是「没有 POSIX 包装层、跑的就是 cmd.exe」)。
370
+ //
371
+ // **不再按 CI 跳过**(2026-10-13):2026-10-12 那版「CI 上默认跳过 W8」是**绕开**当时未定性的
372
+ // `0xC0000374`,而根因已经定位并修好(同一个 PTY 被两个调用方各原生 kill 一次 → 见
373
+ // packages/tty/DEFECTS.md 的 D100 / ROADMAP 的 2026-10-13 一节)。装上闸门后真机(Win11 ARM64)
374
+ // 强制带 W8 连跑 6 轮全部 `12/12 PASS`。**动这里之前先读那条**:它记着当时的崩点分布与 A/B。
375
+ console.log('\n[W8] PowerShell + shell 集成(OSC 133/7)')
376
+ tracer.enter('W8 PowerShell 集成')
377
+ try {
378
+ const shellPath = resolvePowerShell()
379
+ if (shellPath === null) {
380
+ // 本机没有 PowerShell:这是**真跳过**(不是「按设计绕过」),如实进 SKIP 一档——
381
+ // 分母扣掉它、汇总行点名,不许装成 12/12。
382
+ skip('W8 PowerShell shell 集成(本机没找到 pwsh.exe / powershell.exe)')
383
+ } else {
384
+ const app2 = new Context()
385
+ const ws2Fiber = app2.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
386
+ const sub2Fiber = app2.plugin(LocalSubprocessRuntime)
387
+ const stub2Fiber = app2.plugin({
388
+ name: 'windows-smoke-stub-2',
389
+ apply: (ctx) => {
390
+ ctx.provide('settings', { describe: () => [], update: async () => {}, configure: () => () => {} })
391
+ ctx.provide('tools', { register: () => () => {} })
392
+ },
393
+ })
394
+ await stub2Fiber.await()
395
+ const plugin2Fiber = app2.plugin({ name, inject, apply }, { shell: shellPath, term: 'xterm-256color', colorTerm: 'truecolor' })
396
+ await ws2Fiber.await()
397
+ await sub2Fiber.await()
398
+ await plugin2Fiber.await()
399
+ await sleep(400)
400
+
401
+ const s3 = openSession(app2.webServer.port)
402
+ await s3.open()
403
+ s3.client.send(JSON.stringify({ t: 'spawn', cols: 100, rows: 30 }))
404
+ await s3.waitFor(() => s3.state.ready !== null, 25000, 'PowerShell ready')
405
+ // 桩装好 prompt 就发首个 A 标记:到了 = `-File` 桩真的被载入并生效
406
+ await s3.waitFor(() => /\]133;A/.test(s3.state.text), 20000, 'PowerShell 首个 A 标记')
407
+ pass('W8a PowerShell 桩生效(ConPTY 里收到 OSC 133;A)')
408
+
409
+ // `$env:OS` 由 PowerShell 展开(回显里只有字面量),与 W2/W3 同一条判据
410
+ s3.client.send(JSON.stringify({ t: 'input', d: `echo IT_PS_$env:OS${ENTER}` }))
411
+ await s3.waitFor(
412
+ () => /IT_PS_Windows_NT/.test(s3.state.text) && /\]133;D;0/.test(s3.state.text),
413
+ 25000,
414
+ 'PowerShell 的 B/D 与命令输出',
415
+ )
416
+ pass('W8b PowerShell 发全 B/D(退出码 0),且命令真的被执行')
417
+
418
+ s3.client.send(JSON.stringify({ t: 'kill' }))
419
+ try {
420
+ await s3.waitFor(() => s3.state.exited !== null, 15000, 'PowerShell exit')
421
+ } catch {
422
+ /* 没收到 exit 也不影响上面两条结论 */
423
+ }
424
+ try {
425
+ s3.client.close()
426
+ } catch {
427
+ /* 已关闭 */
428
+ }
429
+ }
430
+ } catch (error) {
431
+ fail('W8 PowerShell shell 集成', error.message)
432
+ }
433
+ tracer.leave('W8 PowerShell 集成')
344
434
  }
345
435
 
346
436
  await run()
@@ -348,9 +438,14 @@ await run()
348
438
  // **异常终止**用的,断言失败被说成「崩溃」只会误导。
349
439
  exitingNormally = true
350
440
  const failed = RESULTS.filter((row) => row[0] === 'FAIL')
351
- console.log('\n==== Windows 冒烟:' + String(RESULTS.length - failed.length) + '/' + String(RESULTS.length) + ' PASS ====')
441
+ const skipped = RESULTS.filter((row) => row[0] === 'SKIP')
442
+ /** 分母扣掉 SKIP:**跳过的档不许算进 PASS 数**(否则「跳过」看起来像全过,正是要防的假绿)。 */
443
+ const counted = RESULTS.length - skipped.length
444
+ const summary = String(counted - failed.length) + '/' + String(counted) + ' PASS' +
445
+ (skipped.length > 0 ? '(另 ' + String(skipped.length) + ' 项 SKIP:' + skipped.map((row) => row[1]).join(';') + ')' : '')
446
+ console.log('\n==== Windows 冒烟:' + summary + ' ====')
352
447
  // 同步再打一份结论:万一下面的 flush 屏障之前进程就没了(原生崩溃),至少这一行一定在。
353
- traceSync(`Windows 冒烟结论:${String(RESULTS.length - failed.length)}/${String(RESULTS.length)} PASS`)
448
+ traceSync(`Windows 冒烟结论:${summary}`)
354
449
  for (const row of failed) console.error(' ✘ ' + row[1] + (row[2] ? ' — ' + row[2] : ''))
355
450
  clearTimeout(watchdog)
356
451
  /**