@hyzyn/dsh-tty 0.32.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.
package/README.en.md CHANGED
@@ -129,7 +129,9 @@ Both were reproduced on **Windows 11 ARM (24H2) + Node 22 ARM64**. The fix:
129
129
  banner) but deliberately **not `-NoProfile`** — the user’s profile is where aliases and functions come from.
130
130
  TERM / COLORTERM are meaningless for ConPTY and are no longer injected;
131
131
  - **“Run one command” tabs** (docker exec, command tabs opened by the agent) use `cmd /c` or
132
- `PowerShell -Command`;
132
+ `PowerShell -Command`; **PowerShell’s `-Command` collapses a non-zero last command into process exit code
133
+ 1** (`cmd /c exit 5` → 1, both versions), so to get a concrete code let the command `exit 5` itself (POSIX
134
+ `sh -c` does not behave this way);
133
135
  - **Input line-ending normalisation (D74, reported on real hardware 2026-09-27)**: conhost treats Enter as
134
136
  **CR**, so a bare LF only moves the cursor down and **does not submit the command line** — `tty_send`
135
137
  writing `echo X\n` as the tool description says leaves the command sitting on the input line (no output,
@@ -140,11 +142,16 @@ Both were reproduced on **Windows 11 ARM (24H2) + Node 22 ARM64**. The fix:
140
142
  (`powershell.exe` / `pwsh.exe`) through a stable `-File` stub that hooks `prompt` and
141
143
  `PSConsoleHostReadLine` — this yields cwd tracking (`tty_list.cwd` following `cd`), exit codes, the
142
144
  `running` state and command-granular `tty_expect`. **cmd is not** (it has no command-boundary hooks), so
143
- the settings card greys the switch out and points at “switch the shell path to PowerShell”. **Known
144
- limit**: on PowerShell 7 the command output reaches ConPTY *after* the D marker (pane redraws are
145
- asynchronous and batched — the same root cause as in the tmux case), so `tty_capture{last}` may come back
146
- empty; use `tty_capture` to read the tail there. Windows PowerShell 5.1 is unaffected (both were measured
147
- on a real machine). The stub only loads through `-ExecutionPolicy Bypass` (process-scoped); two boundaries
145
+ the settings card greys the switch out and points at “switch the shell path to PowerShell”. **The two
146
+ versions differ, and the syntax hint is branched by version**: `&&` / `||` are **PowerShell 7** only —
147
+ on 5.1 `&&` is a parse error and not a single command runs. **Known limit (not reproduced)**: it was
148
+ recorded that on PowerShell 7 the command output reaches ConPTY *after* the D marker, so
149
+ `tty_capture{last}` may come back empty; on 2026-10-13 this was re-measured on pwsh **7.6.6** (Win11
150
+ ARM64) across three output shapes (20 small outputs / a 2000-line burst / 20 writes over 2s) with
151
+ **0/20 empty windows** — not reproduced. If you do hit one, read the tail with `tty_capture` and report
152
+ the pwsh version. One more **exit-code rule**: in the PowerShell family a **parse error** (`&&` on 5.1,
153
+ `echo (1` on both) runs nothing at all yet the D marker reports **0** — never judge success by the exit
154
+ code alone; the error text is in the output. The stub only loads through `-ExecutionPolicy Bypass` (process-scoped); two boundaries
148
155
  are recorded as they are: when **Group Policy** locks the execution policy to Restricted / AllSigned that
149
156
  parameter does **not** override GPO, so the stub fails to load and the tab opens with integration silently
150
157
  absent (the same degradation as “the user turned PSReadLine off”); the parameter also writes the value into
@@ -1220,8 +1227,8 @@ verify things like “is there still a white panel after switching light/dark th
1220
1227
  (last 256KB) cannot be recovered.
1221
1228
  - **Shell integration covers zsh / bash on POSIX and the PowerShell family on Windows**: other shells
1222
1229
  (Windows cmd) are skipped automatically (`tty_capture{last}` reports a clear error rather than a wrong
1223
- one); on PowerShell 7 the command output can land after the D marker, so `tty_capture{last}` may be empty
1224
- there (read the tail with `tty_capture` instead). bash < 4.4 uses the DEBUG trap fallback: internal commands
1230
+ one); a PowerShell 7 `tty_capture{last}` empty window was recorded but **not reproduced on pwsh 7.6.6**
1231
+ on 2026-10-13 (0/20 across three output shapes) — if you see one, read the tail with `tty_capture`. bash < 4.4 uses the DEBUG trap fallback: internal commands
1225
1232
  of compound commands such as loops emit extra B markers, and `tty_capture{last}` captures only the output
1226
1233
  after the last internal command for those commands (exit codes and `tty_expect` are unaffected). If the
1227
1234
  user’s rc overrides `PROMPT_COMMAND`/the hook array, the integration may stop working — turn off
package/README.md CHANGED
@@ -119,7 +119,8 @@ dsh plugin --profile web add link:$(pwd)/packages/tty # 仓库开发调试
119
119
  **不补 `-NoProfile`** —— 用户的 profile 正是别名与函数的来源。TERM / COLORTERM 对 ConPTY
120
120
  没有意义,也不再注入;
121
121
  - **「跑一条命令」的标签**(docker exec、agent 起的命令标签)走 `cmd /c` 或
122
- `PowerShell -Command`;
122
+ `PowerShell -Command`;**PowerShell 的 `-Command` 会把「末条命令非零」塌成进程退出码 1**
123
+ (`cmd /c exit 5` → 1,两版一致),要拿到具体码值就让命令自己 `exit 5`(POSIX 侧 `sh -c` 不会这样);
123
124
  - **输入行尾归一化(D74,2026-09-27 真机报告)**:conhost 的 Enter 是 **CR**,裸 LF 只把光标
124
125
  下移一格、**不提交命令行**——`tty_send` 按工具描述发 `echo X\n` 时命令停在输入行上(没有输出、
125
126
  没有新提示符,看起来像「发出去了但没执行」)。**win32 的本地会话**上插件现在把裸 LF 补成 CRLF
@@ -127,9 +128,14 @@ dsh plugin --profile web add link:$(pwd)/packages/tty # 仓库开发调试
127
128
  - **shell 集成(OSC 133/7)按家族分**:**PowerShell 家族支持**(`powershell.exe` / `pwsh.exe`)——
128
129
  注入走 `-File` 稳定桩(挂 `prompt` + `PSConsoleHostReadLine` 钩子),拿到 `tty_list.cwd` 跟随
129
130
  `cd`、退出码、`running` 三态与命令粒度的 `tty_expect`;**cmd 不支持**(没有命令开始/结束钩子),
130
- 设置卡片把开关置灰并指向「把 Shell 路径换成 PowerShell」。**已知限制**:PowerShell 7 上命令输出
131
- 在 ConPTY 里晚于 D 标记抵达(渲染重画是异步批量的,与 tmux 场景同根因),`tty_capture{last}`
132
- 可能取到空 —— 那里用 `tty_capture` 读尾部;Windows PowerShell 5.1 不受影响(两版都真机实测过)。
131
+ 设置卡片把开关置灰并指向「把 Shell 路径换成 PowerShell」。**两版语法不同,提示文案按版本分支给**:
132
+ `&&` / `||` 只有 **PowerShell 7** 认,5.1 上写 `&&` 是解析错误(一条命令都不会跑)。
133
+ **已知限制(未复现)**:曾有记录说 PowerShell 7 上命令输出在 ConPTY 里晚于 D 标记抵达、
134
+ `tty_capture{last}` 可能取到空;2026-10-13 在 pwsh **7.6.6**(Win11 ARM64)上按三档输出量复测
135
+ (20 次小输出 / 2000 行大输出 / 2s 内分 20 次的慢输出)**0/20 空窗**,未复现 —— 真遇到就用
136
+ `tty_capture` 读尾部,并把当时的 pwsh 版本报回来。另有一条**退出码口径**:PowerShell 家族里
137
+ **解析错误**(5.1 上的 `&&`、两版上的 `echo (1`)命令一条都没跑,而 D 标记报 **0** —— 不能只看
138
+ 退出码判成功,报错文本在输出里。
133
139
  桩靠 `-ExecutionPolicy Bypass`(进程级)才载得进来,两条边界如实记着:**组策略**把执行策略锁成
134
140
  Restricted / AllSigned 时该参数压不过 GPO,桩载不进去、标签照开而集成静默失效(与「用户关了
135
141
  PSReadLine」同一种降级);这个参数还会把值写进 `$Env:PSExecutionPolicyPreference`,从该会话里
package/lib/index.d.ts CHANGED
@@ -540,6 +540,14 @@ interface ShellIntegrationState {
540
540
  sawMark: boolean;
541
541
  /** B..D 之间:命令输出捕获中。 */
542
542
  inCommand: boolean;
543
+ /**
544
+ * 当前这条命令**开始的时刻**(见到那个 `B` 的 Date.now();0 = 还没见过 B)。
545
+ *
546
+ * 用途只有一个(D101):区分「命令行回显」与「往在跑的程序里敲的字」。命令行的回显是**在 B
547
+ * 之前**提交的那次 `tty_send`(`session.lastInputAt < commandStartedAt`),而 TUI 里的按键
548
+ * 一定晚于 B —— 后者在屏上出现是真实输出,不许当回显剔掉(D75 的边界)。
549
+ */
550
+ commandStartedAt: number;
543
551
  cmdBuffer: string;
544
552
  /** T 标记带来的 pane 快照(tmux 持久标签;D 时优先于 cmdBuffer)。 */
545
553
  pendingT: string | null;
package/lib/index.js CHANGED
@@ -768,7 +768,7 @@ const FLUSH_INTERVAL_MS = 12;
768
768
  /** 待发输出超过该字符数时跳过窗口立即冲刷(防超长输出无限延迟)。 */
769
769
  const FLUSH_SIZE_CHARS = 64 * 1024;
770
770
  function createShellState() {
771
- return { carry: '', sawMark: false, inCommand: false, cmdBuffer: '', pendingT: null, lastCommand: null, cmdSeq: 0 };
771
+ return { carry: '', sawMark: false, inCommand: false, commandStartedAt: 0, cmdBuffer: '', pendingT: null, lastCommand: null, cmdSeq: 0 };
772
772
  }
773
773
  /** OSC 133;T 的 base64 payload → utf8 文本(无效输入返回 null)。 */
774
774
  function decodeBase64Utf8(payload) {
@@ -842,6 +842,7 @@ export function feedShellIntegration(session, text) {
842
842
  const kind = match[1];
843
843
  if (kind === 'B') {
844
844
  state.inCommand = true;
845
+ state.commandStartedAt = Date.now();
845
846
  state.cmdBuffer = '';
846
847
  state.pendingT = null;
847
848
  }
@@ -1174,6 +1175,66 @@ function lastCommandStart(text) {
1174
1175
  OSC133_RE.lastIndex = 0; // 归位:这个正则是共用的(feedShellIntegration 也用它)
1175
1176
  return end;
1176
1177
  }
1178
+ /**
1179
+ * 从原始流里取出**命令输出**的部分(D101):丢掉每个 `A`(prompt 开始)到其后第一个 `B`
1180
+ * (命令开始)之间的文本,以及末尾那段还没等到 `B` 的 `A` 段。
1181
+ *
1182
+ * 为什么需要它:`A..B` 之间只有提示符与**命令行回显**——真实输出一定在 `B` 之后。这是
1183
+ * shell 集成的硬边界(不是文本启发式)。回溯路径一直在用它(`lastCommandStart` 从最后一个
1184
+ * `B` 起切),而 live 路径此前拿**整段原始流**去匹配,于是「字面出现在命令行里」的 pattern
1185
+ * 会命中自己的回显、秒回 matched —— 命令其实还在跑(D75 的文本剔除在窗口里出现过任意 `B`
1186
+ * 标记时也不启用,见 `echoStrippable`)。
1187
+ *
1188
+ * 真机实测(2026-10-13,Windows 11 ARM64 + pwsh 7.6.6 / Win PowerShell 5.1.26100):命令
1189
+ * `Start-Sleep -Seconds 2; echo PROBE_ECHO_QQQ` + 等 `PROBE_ECHO_QQQ`,两版分别在 **432ms /
1190
+ * 446ms** 就返回 matched;macOS/zsh 上同类用例 **1ms**。修法即本函数:命中前先按边界裁掉
1191
+ * 提示符/回显段。
1192
+ *
1193
+ * 没有标记的会话(cmd、集成被关、TUI 全屏重画)原样返回——降级语义不变。
1194
+ *
1195
+ * 用在两处:`unreadRegion`(回溯窗口,从最后一个 `B` 起切之后再做一次)与
1196
+ * `liveOutputRegion` 的「窗口里没有 `B`」那一支。
1197
+ */
1198
+ function commandOutputRegion(text) {
1199
+ let result = '';
1200
+ let cursor = 0;
1201
+ let inPrompt = false;
1202
+ OSC133_RE.lastIndex = 0;
1203
+ for (const match of text.matchAll(OSC133_RE)) {
1204
+ const index = match.index ?? 0;
1205
+ if (!inPrompt)
1206
+ result += text.slice(cursor, index);
1207
+ if (match[1] === 'A')
1208
+ inPrompt = true;
1209
+ else if (match[1] === 'B')
1210
+ inPrompt = false;
1211
+ cursor = index + match[0].length;
1212
+ }
1213
+ if (!inPrompt)
1214
+ result += text.slice(cursor);
1215
+ OSC133_RE.lastIndex = 0; // 归位:这个正则是共用的(feedShellIntegration 也用它)
1216
+ return result;
1217
+ }
1218
+ /**
1219
+ * `tty_expect` 的 live 窗口:从「本次等待期间到达的原始流」里取出**命令输出**(D101)。
1220
+ *
1221
+ * 三种现场分别处理,判据都是 shell 集成的边界,不做文本启发式:
1222
+ * 1. 窗口里有 `B`(命令在本次等待期间开始了)→ 只取最后一个 `B` 之后。**命令行回显在 `B` 之前**
1223
+ * (真机上回显与 `B` 常在同一 chunk 里、回显在前),所以这一步就把回显丢掉了;
1224
+ * 2. 窗口里没有 `B` 但 `inCommand`(`B` 早于注册)→ 整段都是那个命令的输出(TUI 全屏重画、
1225
+ * 长任务刷屏),原样返回;
1226
+ * 3. 都没有 → 没有集成(cmd / 集成被关)或正停在提示符上:此时可能只有提示符与回显,也可能
1227
+ * 是没人发命令时自己冒出来的后台输出 —— 只能交给 `commandOutputRegion` 裁掉「未闭合的
1228
+ * `A` 段」(提示符 + 正在回显的命令行),其余照旧返回(后台输出不能被误杀)。
1229
+ */
1230
+ function liveOutputRegion(text, state) {
1231
+ const fromB = lastCommandStart(text);
1232
+ if (fromB !== -1)
1233
+ return text.slice(fromB);
1234
+ if (state.inCommand)
1235
+ return text;
1236
+ return commandOutputRegion(text);
1237
+ }
1177
1238
  /** 首次被 agent 工具触达时把水位线落在当下——否则第一次回扫会把用户此前的历史输出当成「还没读过」。 */
1178
1239
  function ensureReadMark(session) {
1179
1240
  if (session.readSeq >= 0)
@@ -1200,7 +1261,9 @@ function unreadRegion(session) {
1200
1261
  const bufferStart = session.outputSeq - session.buffer.length;
1201
1262
  const text = session.buffer.slice(Math.max(0, session.readSeq - bufferStart));
1202
1263
  const start = lastCommandStart(text);
1203
- return start === -1 ? text : text.slice(start);
1264
+ // D101:从最后一个 B 起切只解决了「回显在 B 之前」那一半;这段里还可能有一段**还没等到 B**
1265
+ // 的 A 段(提示符 + 正在回显的命令行),同样要在匹配前裁掉。
1266
+ return commandOutputRegion(start === -1 ? text : text.slice(start));
1204
1267
  }
1205
1268
  /**
1206
1269
  * pattern 匹配的统一入口(D72):先对含 ANSI 的原始流试(live 路径原语义),
@@ -1227,6 +1290,13 @@ function testPattern(re, text) {
1227
1290
  * - 真实输出里也出现的标记照旧命中(不误杀);
1228
1291
  * - 只在回显行**末尾**匹配才削(cmd 的回显与提示符同行,所以按行尾切);
1229
1292
  * - 有 B 标记 / 命令正在跑(TUI 全屏重画里出现输入文本是正常输出)时不启用。
1293
+ *
1294
+ * **D101 补**:上面第三条让这条剔除在真实会话里几乎从不启用——窗口里出现**前一条命令**的
1295
+ * `B` 标记就是常态,「命令正在跑」更是常态。真机实测(2026-10-13):`Start-Sleep -Seconds 2;
1296
+ * echo MARK` + 等 `MARK`,Windows PowerShell 5.1 / 7 分别 446ms / 432ms 就 matched,macOS/zsh
1297
+ * 上 1ms。所以回显的**主防线**改成零启发式的 `commandOutputRegion`(按 `A..B` 边界裁,
1298
+ * `tty_expect` 的 live 路径与 `unreadRegion` 都走它);本函数退成没有标记那种壳(cmd /
1299
+ * 集成被关——那里没有边界可用,只能按提交过的输入做文本剔除)的兜底。
1230
1300
  * ------------------------------------------------------------------ */
1231
1301
  /** 回显候选的行数上限(最近几条提交的输入)与单行长度上限。 */
1232
1302
  const ECHO_INPUT_CAP = 8;
@@ -1257,13 +1327,21 @@ function noteSubmittedInput(session, data) {
1257
1327
  function echoStrippable(session, text) {
1258
1328
  if (session.recentInputs.length === 0)
1259
1329
  return false;
1330
+ /*
1331
+ * 命令正在跑时(D101 修正):启用与否**不能**只看 inCommand —— 真机现场是「命令已经被接受
1332
+ * (B 早于注册)、回显稍后才回到缓冲区」,那时精确命中命令行文本的就是回显(Windows
1333
+ * PowerShell 上 432ms 就 matched,而命令还在 Start-Sleep)。
1334
+ *
1335
+ * 判据换成「最近一次提交**早于本条命令的开始**」:那是命令行本身(回显就该剔掉);提交晚于
1336
+ * 命令开始的,是往在跑的程序里敲的键 —— 它在屏上出现属于**真实输出**,不许剔(D75 的边界)。
1337
+ */
1260
1338
  if (session.shellState.inCommand)
1261
- return false;
1339
+ return session.lastInputAt < session.shellState.commandStartedAt;
1262
1340
  return lastCommandStart(text) === -1;
1263
1341
  }
1264
1342
  /**
1265
1343
  * 把回显候选行从文本里削掉(保行结构)。按**行尾**匹配:cmd 的回显与提示符同行
1266
- * (`C:\>echo X`),所以只削后缀、保留提示符前缀。行尾空白(含 `\r`)先归一。
1344
+ * (`C:\>echo X`),所以只削后缀、保留提示符前缀。行尾空白与控制字节先归一。
1267
1345
  */
1268
1346
  function stripEchoLines(text, echoes) {
1269
1347
  if (echoes.length === 0)
@@ -1272,7 +1350,12 @@ function stripEchoLines(text, echoes) {
1272
1350
  return text
1273
1351
  .split('\n')
1274
1352
  .map((line) => {
1275
- const trimmed = line.replace(/[ \t\r]+$/, '');
1353
+ /*
1354
+ * 行尾的空白与**控制字节**都算噪音(D101 真机实测):zsh 在命令行后面紧跟着写
1355
+ * bracketed-paste 开关 `\e[?2004l`,不把它剥掉,「按行尾削」就永远削不掉那一行——
1356
+ * 于是回显被当成真输出(macOS/zsh 上实测 1ms 命中)。
1357
+ */
1358
+ const trimmed = line.replace(/[\s\x00-\x1f\x7f]+$/, '');
1276
1359
  for (const echo of sorted) {
1277
1360
  if (trimmed.endsWith(echo))
1278
1361
  return trimmed.slice(0, trimmed.length - echo.length);
@@ -1283,14 +1366,16 @@ function stripEchoLines(text, echoes) {
1283
1366
  }
1284
1367
  /**
1285
1368
  * 命中是否**只**落在回显上(纯回显 → 不算命中)。
1286
- * 原始流与清洗后文本各削一次:回显行里可能夹着 ANSI(zsh 的 zle / 彩色提示符)。
1369
+ *
1370
+ * D101 起只看**清洗后**的文本:原来原始流与清洗文本各削一次、要求「两边都不再命中」才算回显,
1371
+ * 于是原始流里跟在命令行后面的转义序列(`\e[?2004l`、zle 的光标移动……)让 raw 变体削不干净、
1372
+ * 照样命中 → 整条判据失效。清洗后的文本才是「agent 真正看得到的那一份」(`tty_capture` 与
1373
+ * `tty_expect` 返回的也是它),按它判定既够用又不自相矛盾。
1287
1374
  */
1288
1375
  function echoOnlyMatch(re, text, session) {
1289
1376
  if (!echoStrippable(session, text))
1290
1377
  return false;
1291
- const stripped = stripEchoLines(text, session.recentInputs);
1292
- const strippedClean = stripEchoLines(cleanAnsiTail(text), session.recentInputs);
1293
- return !testPattern(re, stripped) && !testPattern(re, strippedClean);
1378
+ return !testPattern(re, stripEchoLines(cleanAnsiTail(text), session.recentInputs));
1294
1379
  }
1295
1380
  /** 宽松清洗一份 tunnels 输入;输入不是数组时返回 undefined(表示「未提供,保持原值」)。 */
1296
1381
  function sanitizeTunnels(input) {
@@ -5614,7 +5699,10 @@ const plugin = definePlugin({
5614
5699
  // 的收窄,闭包里就得再判一次 undefined。
5615
5700
  const onData = (chunk) => {
5616
5701
  acc = (acc + decoder.write(chunk)).slice(-64 * 1024);
5617
- const hay = acc.length > 16 * 1024 ? acc.slice(-16 * 1024) : acc;
5702
+ // D101:先按 shell 集成的命令边界裁掉提示符/回显段(见 liveOutputRegion),
5703
+ // 再取 16KB 尾部窗口。反过来(先切 16KB 再裁)会切掉段首那个 B,回显又漏回窗口里。
5704
+ const region = liveOutputRegion(acc, state);
5705
+ const hay = region.length > 16 * 1024 ? region.slice(-16 * 1024) : region;
5618
5706
  if (testPattern(re, hay)) {
5619
5707
  // D75:命中的若**只是刚送进去那行命令的回显**,不算命中——继续等真输出
5620
5708
  if (echoOnlyMatch(re, hay, session))