@hyzyn/dsh-tty 0.22.0-rc.1 → 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.
- package/README.en.md +68 -10
- package/README.md +57 -10
- package/client.js +16 -1
- package/lib/index.d.ts +144 -25
- package/lib/index.js +440 -62
- package/lib/index.js.map +1 -1
- package/lib/shell-integration.js +25 -1
- package/lib/shell-integration.js.map +1 -1
- package/package.json +1 -1
- package/scripts/integration.mjs +132 -1
- package/scripts/windows-smoke.mjs +100 -1
package/README.en.md
CHANGED
|
@@ -76,6 +76,12 @@ Both were reproduced on **Windows 11 ARM (24H2) + Node 22 ARM64**. The fix:
|
|
|
76
76
|
TERM / COLORTERM are meaningless for ConPTY and are no longer injected;
|
|
77
77
|
- **“Run one command” tabs** (docker exec, command tabs opened by the agent) use `cmd /c` or
|
|
78
78
|
`PowerShell -Command`;
|
|
79
|
+
- **Input line-ending normalisation (D74, reported on real hardware 2026-09-27)**: conhost treats Enter as
|
|
80
|
+
**CR**, so a bare LF only moves the cursor down and **does not submit the command line** — `tty_send`
|
|
81
|
+
writing `echo X\n` as the tool description says leaves the command sitting on the input line (no output,
|
|
82
|
+
no new prompt, looking like “it was sent but never ran”). For **local sessions on win32** the plugin now
|
|
83
|
+
turns a bare LF into CRLF (an existing CRLF is not doubled; non-Windows and SSH sessions pass through
|
|
84
|
+
untouched), so the documented `\n` path just works;
|
|
79
85
|
- **Three things are not supported** (the host turns them off and the settings card explains why):
|
|
80
86
|
- **Shell integration (OSC 133/7)**: injection relies on POSIX rc stubs plus the `-c` wrapper, neither of
|
|
81
87
|
which exists for cmd / PowerShell, so it is permanently off — **local** tabs therefore lose cwd tracking
|
|
@@ -92,7 +98,9 @@ Both were reproduced on **Windows 11 ARM (24H2) + Node 22 ARM64**. The fix:
|
|
|
92
98
|
rebuild), plus `[dsh-tty] mounted (shell=C:\WINDOWS\system32\cmd.exe)`.
|
|
93
99
|
x64 Windows is covered by the CI matrix (build / typecheck / test, see Development).
|
|
94
100
|
- **End-to-end coverage (0.19.0)**: `scripts/windows-smoke.mjs` drives a real `cmd.exe` through
|
|
95
|
-
spawn → input echo → kill → respawn on the CI windows-latest runner
|
|
101
|
+
spawn → input echo → kill → respawn on the CI windows-latest runner, plus two links that only hold on
|
|
102
|
+
a real machine (W6: after normalisation a bare LF really does submit in conhost, D74; W7: a `command`
|
|
103
|
+
session that exits turns into read-only retention and `tty_capture` still reads its pre-exit output, D77). It caught and now pins a
|
|
96
104
|
Windows-only failure class: force-killing a local PTY crashed the host — node-pty rejects signals
|
|
97
105
|
on Windows, and its `_deferNoArgs` re-throws that error from a socket callback, where the caller’s
|
|
98
106
|
try/catch cannot see it.
|
|
@@ -103,14 +111,14 @@ The plugin injects sixteen tools into the agent (with the same power as the bash
|
|
|
103
111
|
|
|
104
112
|
| Tool | Purpose |
|
|
105
113
|
| --- | --- |
|
|
106
|
-
| `tty_list` | List
|
|
107
|
-
| `tty_open` | **Open a terminal session yourself** (0.20.0): a local shell, or a
|
|
108
|
-
| `tty_close` | Close a session opened by `tty_open` (0.20.0). **Only the agent’s own sessions may be closed**: a tab the user opened is refused, so the agent never ends a terminal the user is working in |
|
|
114
|
+
| `tty_list` | List terminal sessions (sid / kind (`local\|ssh`) / target / pid / **cwd tracked live as you `cd`** / activity time; tmux persistent sessions carry a `persist` marker; sessions the agent opened carry `owner: 'agent'`). **Includes sessions whose process has exited but which are still inside their read-only retention window** (`exited:true` + exit code/signal, see below) |
|
|
115
|
+
| `tty_open` | **Open a terminal session yourself** (0.20.0): a local shell, or a command via `command` (dev server / watch; it runs as **a whole piece of shell code** — `cd x && cmd`, `a; b`, even a multi-line script, and it is *not* subject to the “single line ≤2000” client rule below — D78), with optional tmux persistence via `persistName`. The session **shows up in the user’s terminal panel** as an ordinary tab the user can see and take over — never a hidden session |
|
|
116
|
+
| `tty_close` | Close a session opened by `tty_open` (0.20.0). **Only the agent’s own sessions may be closed**: a tab the user opened is refused, so the agent never ends a terminal the user is working in. It also works on an **exited session that is still in read-only retention** — that is its release entry point (by default it is retained **until explicitly closed**, never on a timer) |
|
|
109
117
|
| `tty_stats` | Read live host metrics for a session’s machine (0.20.0): CPU / memory / disk / TCP connections / network rates / temperature / uptime. Local sessions report the host; SSH sessions report that remote host over a separate non-PTY channel that never touches the terminal. Check it before deploying or load-testing |
|
|
110
118
|
| `tty_capture` | Read recent output (last N lines, ANSI stripped by default, `raw:true` for the raw stream); **`last:true` returns only the output + exit code of the previous completed command** (shell integration markers, see the next section); when a command is **in flight** (just sent, completion marker not in yet) it returns `inProgress:true` without the stale result, so the previous command is never mistaken for this one (0.19.0) |
|
|
111
119
|
| `tty_screen` | Read the **currently visible screen** as rendered (xterm-headless virtual screen, plain text) — it can genuinely read TUI interfaces such as vim / htop / menus |
|
|
112
|
-
| `tty_expect` | Wait with a regex for a readiness signal (dev server URL, build finished, …). It **looks back first** at output that has not been read yet (including the full output of “the previous command”, so a command that finished instantly no longer burns the whole timeout) and then waits for subsequent output; on a hit it returns `matched:true` plus `matchedFrom` (`live` produced during this wait / `last` the previous command’s output / `buffered` buffered output that had already arrived). A timeout does not throw (`matched:false` + tail output, and it **does not consume the unread region**, so a different pattern can still look back at the same output), and a command that ends early also returns early with its exit code; the echo never counts as a hit (via the OSC 133 A..B boundary — see the next section
|
|
113
|
-
| `tty_send` | Send keys/text to a given session (such as `q` to a dev server, or a menu selection) |
|
|
120
|
+
| `tty_expect` | Wait with a regex for a readiness signal (dev server URL, build finished, …). It **looks back first** at output that has not been read yet (including the full output of “the previous command”, so a command that finished instantly no longer burns the whole timeout) and then waits for subsequent output; on a hit it returns `matched:true` plus `matchedFrom` (`live` produced during this wait / `last` the previous command’s output / `buffered` buffered output that had already arrived). A timeout does not throw (`matched:false` + tail output, and it **does not consume the unread region**, so a different pattern can still look back at the same output), and a command that ends early also returns early with its exit code; **the echo of the command just sent never counts as a hit** (via the OSC 133 A..B boundary, or via the send record where there are no markers — see the next section; when only the echo ever matched, the timeout result carries `echoOnly:true`); at most 5 in-flight calls per session, and the accumulated window keeps only the last 64KB (0.19.0; look-back matching, see D72; echo exclusion, see D75) |
|
|
121
|
+
| `tty_send` | Send keys/text to a given session (such as `q` to a dev server, or a menu selection). End a command with `\n`: on a **local Windows session** a trailing bare LF is normalised to CRLF (D74, see the “Windows hosts” section); non-Windows and SSH sessions pass through untouched. On an **exited** session it fails explicitly (such sessions are read-only) |
|
|
114
122
|
| `sftp_list` | List a remote SSH directory (name/type/size/mtime, directories first); `book` is the connection-book entry name and `path` defaults to the login home; at most 500 entries by default (`truncated:true` beyond that), and `isSymlink` distinguishes a symlink from a real directory (0.19.0) |
|
|
115
123
|
| `sftp_read` | Read a remote **text** file (≤256KB by default, adjustable to 1MB, truncated beyond that); `offset` pages from a given byte (handy for log tails), an invalid `maxBytes` errors out instead of silently falling back, and binary detection is a double test (NUL + illegal-UTF-8 ratio) (0.19.0) |
|
|
116
124
|
| `sftp_write` | Write a remote text file (overwrite by default, `append:true` appends; ≤1MB per call) |
|
|
@@ -134,6 +142,39 @@ the orphan collector** (which only reaps disconnected *user* sessions); it ends
|
|
|
134
142
|
`tty_close` or the user closing the tab. Conversely the agent **cannot close a user-opened tab**
|
|
135
143
|
(`tty_close` refuses explicitly).
|
|
136
144
|
|
|
145
|
+
### Read-only retention after the process exits (D77)
|
|
146
|
+
|
|
147
|
+
A process exit **no longer means the session is gone** — the session turns into a **read-only retained** one,
|
|
148
|
+
**kept until it is explicitly closed** by default (`EXITED_RETAIN_MS = ∞`; a host/plugin restart clears them).
|
|
149
|
+
While retained:
|
|
150
|
+
|
|
151
|
+
- **Reads keep working**: `tty_capture` (tail or `last`, including the final batch of output printed
|
|
152
|
+
before exit), `tty_screen` (the screen as of the exit) and `tty_list` (entry carries `exited:true` +
|
|
153
|
+
`exitCode`/`signal`) all still answer, and their results carry an `exited` marker;
|
|
154
|
+
`tty_expect` no longer burns its timeout — it settles against the existing output and returns
|
|
155
|
+
`exited:true`;
|
|
156
|
+
- **Writes are refused**: `tty_send` reports an explicit error (the process is gone; a write would
|
|
157
|
+
vanish into a dead PTY), and `tty_resize` / `tty_stats` no longer apply to it (stats honestly returns
|
|
158
|
+
`available:false`);
|
|
159
|
+
- **No re-attaching a terminal**: `attach` is explicitly refused (there is no live PTY; the panel does
|
|
160
|
+
not create a tab for it either) — read the output with `tty_capture`;
|
|
161
|
+
- **Who releases it**: the agent’s `tty_close` (its own sessions only), the user closing that tab in the
|
|
162
|
+
panel, or a host/plugin restart (retention lives in memory). The concurrency limit counts **live
|
|
163
|
+
sessions only** (retained ones do not consume a slot), and at most `MAX_EXITED_SESSIONS` (8) retained
|
|
164
|
+
sessions are kept — the oldest is evicted beyond that (each eviction logs the sid it drops). So the
|
|
165
|
+
rule is “the last 16 exited sessions stay readable”, which is the only bound on memory and handles
|
|
166
|
+
(16 of them is on the order of ~20MB).
|
|
167
|
+
|
|
168
|
+
Why **not** a timer: memory and handles are not actually time-driven (see above), while a deadline is a
|
|
169
|
+
second surprise for whoever reads the output — the gap between “the command finished” and “the next
|
|
170
|
+
round reads the result” spans people walking away and queued model turns. An output that vanishes on
|
|
171
|
+
schedule is harder to reason about than one you have to close. If you do want a time bound (or no
|
|
172
|
+
retention at all), set `EXITED_RETAIN_MS` to a millisecond value / 0 — the `reapExited` path is still
|
|
173
|
+
wired into the reaper, and `tty_list`’s `retainMs` field only appears when a timer is in play.
|
|
174
|
+
|
|
175
|
+
This is why “`tty_open` a command that finishes and read the result afterwards” now just works — no
|
|
176
|
+
`sh` wrapper needed (the request in issue #4).
|
|
177
|
+
|
|
137
178
|
### Shell integration (OSC 133/7, 0.4.0)
|
|
138
179
|
|
|
139
180
|
At spawn time hooks are injected through the existing `-c` wrapper layer according to the shell type (transparent to the user, no rc changes):
|
|
@@ -159,8 +200,15 @@ the echo sits between `A` and `B`, so text in the echo never counts as a hit (de
|
|
|
159
200
|
comparison here: line wrapping / ANSI / multi-line paste would break it).
|
|
160
201
|
|
|
161
202
|
- **Environments without `B` markers** (integration off / local Windows tabs / a remote host without the
|
|
162
|
-
hooks)
|
|
163
|
-
|
|
203
|
+
hooks) have no boundary to cut at, so since D75 the echo is excluded by a different test: the command
|
|
204
|
+
lines that the most recent `tty_send` submitted are stripped out of the candidate text and the pattern
|
|
205
|
+
is retried — only “no longer matches after stripping” counts as a pure echo. That removes the false
|
|
206
|
+
positive where the command never ran yet the wait reported `matched:true`, while the same text really
|
|
207
|
+
printed later still matches. When the wait times out and the pattern only ever matched the echo, the
|
|
208
|
+
result carries `echoOnly:true` and the message says plainly that the command was probably never
|
|
209
|
+
executed. **Boundary**: only lines written by `tty_send` are known (typing in the panel is not
|
|
210
|
+
simulated as line editing), and stripping is disabled while a command is running (in a full-screen TUI
|
|
211
|
+
redraw, the typed text appearing on screen is real output);
|
|
164
212
|
- **The tradeoff in that look-back range**: raising the lower bound to “just after the last `B` marker”
|
|
165
213
|
costs you the unread output that precedes the newest command’s start. It deliberately prefers a miss
|
|
166
214
|
over a false hit — treating the echo as a match would make the agent believe the event just happened.
|
|
@@ -672,13 +720,23 @@ ctx.inject(['ttyTerminal'], (c) => {
|
|
|
672
720
|
|
|
673
721
|
- Command tabs are **not persisted in tmux** (commands are short-lived and attaching is meaningless) and do
|
|
674
722
|
not go through a login shell; the SSH side uses `conn.exec(command, {pty})`, and the local side uses
|
|
675
|
-
`sh -c 'export TERM=…; exec <command>'
|
|
723
|
+
`sh -c 'export TERM=…; exec <the same shell> -c <command>'` — the outer `exec` makes the process
|
|
724
|
+
the command itself (faithful exit code and signal), and the **inner `-c`** is what makes the whole
|
|
725
|
+
string run as shell code: `cd x && cmd`, `a; b`, `for …; do …; done` all run to completion
|
|
726
|
+
(D78: the old form appended the command straight after `exec`, so only the first one ran and a
|
|
727
|
+
builtin such as `cd` ended the wrapper shell outright).
|
|
676
728
|
- **Command tabs reopen automatically**: after a host restart / reconnect the sid is gone, and the client
|
|
677
729
|
re-runs the command from the original spec for tabs with `spawnSpec.command` (ordinary non-persistent tabs
|
|
678
730
|
keep the old “click to retry” behavior). After a page refresh they are restored by the original command too.
|
|
679
731
|
- The command comes from a **host-side plugin** (not from remote user input), so its trust level equals the
|
|
680
732
|
plugin’s own; tty only validates the shape: non-empty, single line, length ≤2000 (a newline would break the
|
|
681
733
|
local `-c` wrapper layer).
|
|
734
|
+
- **“Single line, ≤2000” is this channel’s rule, not `tty_open`’s** (raised in the 2026-09-27 review): the
|
|
735
|
+
`command` in a `spawn` / `ssh` frame gets spliced into the `-c` wrapper, so `sanitizeCommand` rejects
|
|
736
|
+
newlines and NUL. The agent’s `tty_open.command` takes a different path — the command is wrapped **whole**
|
|
737
|
+
in `shSingleQuote` and handed to the inner `-c` (see above), so it **may contain newlines** (multi-line
|
|
738
|
+
scripts do run) and is bounded only by the tool parameter’s string type. Do not read this section’s limit
|
|
739
|
+
as a `tty_open` restriction.
|
|
682
740
|
- The service name `ttyTerminal` is likewise not declared on the `Context` type surface; inject it as an
|
|
683
741
|
optional dependency; when tty is not installed or is older than 0.14.0 it never fires (dsh-docker degrades
|
|
684
742
|
to “copy command”).
|
|
@@ -816,7 +874,7 @@ ctx.inject(['ttyPanel'], (c) => {
|
|
|
816
874
|
| C→S | `{t:'attach', sid}` | Reattach an orphan session (inside the keep-alive window): after `ready(reattached:true)` a single `data` frame replays the output buffer |
|
|
817
875
|
| C→S | `{t:'statsOn' \| 'statsOff', sid}` | Subscribe/unsubscribe that session’s server status bar (0.17.0): driven by tab visibility, and the host collects only while a subscription exists (lazy start + unsubscribing stops the meter and closes the remote channel) |
|
|
818
876
|
| S→C | `{t:'ready', sid, pid, kind, target?, persist?, reattached?}` | Session ready; `kind:'local'` carries a pid, while `kind:'ssh'` has pid=null and target=user@host[:port]; attach reuses this frame with `reattached:true`; `persist:true` means a tmux persistent session (0.10.0) |
|
|
819
|
-
| S→C | `{t:'data', sid, d}` | Terminal output (utf8 text, StringDecoder covers multi-byte sequences split across frames); **coalesced into frames over a 12ms window / 64KB threshold** (0.4.1), with a forced flush before exit/kill to guarantee frame order |
|
|
877
|
+
| S→C | `{t:'data', sid, d}` | Terminal output (utf8 text, StringDecoder covers multi-byte sequences split across frames); **coalesced into frames over a 12ms window / 64KB threshold** (0.4.1), with a forced flush before exit/kill to guarantee frame order — **the last batch before exit is always sent** (D76: it used to be swallowed whole by the exit path’s own `closed` guard, so a process that printed and exited immediately was permanently missing its final lines) |
|
|
820
878
|
| S→C | `{t:'stats', sid, stats}` | Resource metric frame (0.17.0): `{cpuPct, cores, memUsed, memTotal, memPct, diskUsed, diskTotal, diskPct, uptimeSec, tcpConns, rxRate, txRate, tempC?}`; missing fields are omitted (best-effort, the frontend shows “n/a”), byte fields are bytes and rates are B/s |
|
|
821
879
|
| S→C | `{t:'exit', sid, code, signal}` | The PTY exit fact (exactly once; after attach moves to a new connection it is still delivered over the current connection) |
|
|
822
880
|
| S→C | `{t:'error', sid?, m}` | Error |
|
package/README.md
CHANGED
|
@@ -75,6 +75,10 @@ dsh plugin --profile web add link:$(pwd)/packages/tty # 仓库开发调试
|
|
|
75
75
|
没有意义,也不再注入;
|
|
76
76
|
- **「跑一条命令」的标签**(docker exec、agent 起的命令标签)走 `cmd /c` 或
|
|
77
77
|
`PowerShell -Command`;
|
|
78
|
+
- **输入行尾归一化(D74,2026-09-27 真机报告)**:conhost 的 Enter 是 **CR**,裸 LF 只把光标
|
|
79
|
+
下移一格、**不提交命令行**——`tty_send` 按工具描述发 `echo X\n` 时命令停在输入行上(没有输出、
|
|
80
|
+
没有新提示符,看起来像「发出去了但没执行」)。**win32 的本地会话**上插件现在把裸 LF 补成 CRLF
|
|
81
|
+
(已经是 CRLF 的不重复补;非 Windows 与 SSH 会话原样透传),所以照描述写 `\n` 这条主路径直接可用;
|
|
78
82
|
- **不支持的三项(宿主侧自动关掉,设置卡片里写明原因)**:
|
|
79
83
|
- **shell 集成(OSC 133/7)**:注入靠 POSIX rc 桩 + `-c` 包装层,cmd / PowerShell 上都不成立,
|
|
80
84
|
因此恒关 —— **本地标签**的 `tty_list.cwd` 跟随 `cd`、`tty_capture{last}` 与 `tty_expect`
|
|
@@ -87,7 +91,9 @@ dsh plugin --profile web add link:$(pwd)/packages/tty # 仓库开发调试
|
|
|
87
91
|
内容标记一致;不写死字节数——每次重建都会变)、`[dsh-tty] mounted (shell=C:\WINDOWS\system32\cmd.exe)`;x64 Windows 由 CI
|
|
88
92
|
的三平台矩阵覆盖(build / typecheck / test,见「开发」)。
|
|
89
93
|
- **端到端覆盖(0.19.0)**:`scripts/windows-smoke.mjs` 在 CI 的 windows-latest 上跑真实 `cmd.exe`
|
|
90
|
-
的 spawn → 输入回显 → kill →
|
|
94
|
+
的 spawn → 输入回显 → kill → 重开,外加两条只在真机上成立的链路(W6:`tty_send` 的裸 LF 经归一化
|
|
95
|
+
后 conhost 真的执行了命令,D74;W7:`command` 型会话跑完退出后转只读保留、`tty_capture` 仍读得到
|
|
96
|
+
退出前的输出,D77)。它抓出并钉住了「强杀本地 PTY 会崩宿主」这类只在 Windows
|
|
91
97
|
出现的问题——node-pty 在 Windows 上不接受 signal,而它的 `_deferNoArgs` 会把这个异常推迟到
|
|
92
98
|
socket 回调里抛出,调用方的 try/catch 拦不住。
|
|
93
99
|
|
|
@@ -97,14 +103,14 @@ dsh plugin --profile web add link:$(pwd)/packages/tty # 仓库开发调试
|
|
|
97
103
|
|
|
98
104
|
| 工具 | 作用 |
|
|
99
105
|
| --- | --- |
|
|
100
|
-
| `tty_list` |
|
|
101
|
-
| `tty_open` | **自己开一个终端会话**(0.20.0):本地 shell,或 `command`
|
|
102
|
-
| `tty_close` | 关掉一个由 `tty_open` 开的会话(0.20.0)。**只允许关 agent 自己开的**:用户在面板里开的标签会被拒绝——agent
|
|
106
|
+
| `tty_list` | 列出终端会话(sid / kind(local\|ssh)/ target / pid / **cwd 实时跟随 cd** / 活动时间;tmux 持久会话带 `persist` 标记;agent 自己开的带 `owner: 'agent'`)。**含进程已退出但仍在只读保留期内的会话**(`exited:true` + 退出码/信号,见下节) |
|
|
107
|
+
| `tty_open` | **自己开一个终端会话**(0.20.0):本地 shell,或 `command` 直接跑一条命令(dev server / watch;**按整段 shell 代码执行**——`cd x && cmd`、`a; b`、多行脚本都可以,且不受下面「命令标签单行 ≤2000」那条客户端约束,D78),`persistName` 可要 tmux 持久化。**开出来的会话出现在用户的终端面板里**(普通标签、用户可见可接管),不做隐形会话 |
|
|
108
|
+
| `tty_close` | 关掉一个由 `tty_open` 开的会话(0.20.0)。**只允许关 agent 自己开的**:用户在面板里开的标签会被拒绝——agent 不越权结束用户正在用的终端。对**已退出但仍只读保留着**的会话同样可用——那是它的释放入口(默认**保留到显式关闭**,不按时间释放) |
|
|
103
109
|
| `tty_stats` | 读会话所在机器的实时指标(0.20.0):CPU / 内存 / 磁盘 / TCP 连接数 / 网速 / 温度 / 在线时长。本地会话取宿主机;SSH 会话取那台远程主机(另开一段非 PTY 通道,不影响终端)。部署、压测前先看它 |
|
|
104
110
|
| `tty_capture` | 读取近期输出(尾部 N 行,默认清洗 ANSI,`raw:true` 取原始流);**`last:true` 只返回上一条已完成命令的输出 + 退出码**(shell 集成标记,见下节);命令**在途**时(刚发送、完成标记未到)返回 `inProgress:true` 且不带旧结果——避免把上一条的输出当成这一条(0.19.0) |
|
|
105
111
|
| `tty_screen` | 读取**当前可见屏幕**的渲染结果(xterm-headless 虚拟屏,纯文本)——能真正读懂 vim / htop / 菜单等 TUI 界面 |
|
|
106
|
-
| `tty_expect` | 用正则等一个就绪信号(dev server URL、构建完成等)。**先回溯**还没被读过的输出(含「上一条命令」的完整输出,故命令瞬间跑完也不会白等),再等后续输出;命中即返回 `matched:true` + `matchedFrom`(`live` 本次等待期间新产生 / `last` 上一条命令的输出 / `buffered` 此前已到达的缓冲输出)。超时不抛错(`matched:false` + 尾部输出,且**不消耗未读区**——换个 pattern
|
|
107
|
-
| `tty_send` | 向指定会话发送按键/文本(如 dev server 的 q
|
|
112
|
+
| `tty_expect` | 用正则等一个就绪信号(dev server URL、构建完成等)。**先回溯**还没被读过的输出(含「上一条命令」的完整输出,故命令瞬间跑完也不会白等),再等后续输出;命中即返回 `matched:true` + `matchedFrom`(`live` 本次等待期间新产生 / `last` 上一条命令的输出 / `buffered` 此前已到达的缓冲输出)。超时不抛错(`matched:false` + 尾部输出,且**不消耗未读区**——换个 pattern 还能回溯到同一段),命令提前结束也会带退出码早停;**刚发进去的命令回显不算命中**(靠 OSC 133 的 A..B 边界,无标记环境靠发送记录剔除,见下节;只命中回显时超时结果带 `echoOnly:true`);同一会话在途调用最多 5 个,累积窗口只保留尾部 64KB(0.19.0;回溯匹配见 D72,回显剔除见 D75) |
|
|
113
|
+
| `tty_send` | 向指定会话发送按键/文本(如 dev server 的 q 键、菜单选择)。命令以 `\n` 结尾即可:**Windows 本地会话**上行尾裸 LF 会被归一成 CRLF(D74,见「Windows 宿主」一节),非 Windows 与 SSH 会话原样透传。对**已退出**的会话会明确报错(那种会话只能读) |
|
|
108
114
|
| `sftp_list` | 列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前);`book` 为连接簿条目名,`path` 缺省为登录 home;默认最多 500 项(超限 `truncated:true`),`isSymlink` 区分软链与真目录(0.19.0) |
|
|
109
115
|
| `sftp_read` | 读取远程**文本**文件(默认 ≤256KB 可调至 1MB,超出截断);`offset` 可从指定字节分页(适合读日志尾部),非法 `maxBytes` 直接报错,二进制判定 = NUL + 非法 UTF-8 占比双判据(0.19.0) |
|
|
110
116
|
| `sftp_write` | 写远程文本文件(默认覆盖,`append:true` 追加;单次 ≤1MB) |
|
|
@@ -125,6 +131,33 @@ dsh plugin --profile web add link:$(pwd)/packages/tty # 仓库开发调试
|
|
|
125
131
|
(回收器只收「断连的用户会话」),关闭入口是 agent 的 `tty_close` 或用户在面板里关标签;
|
|
126
132
|
反过来说,agent 也**关不掉用户开的标签**(`tty_close` 会明确拒绝)。
|
|
127
133
|
|
|
134
|
+
### 进程退出后的「只读保留」(D77)
|
|
135
|
+
|
|
136
|
+
会话的进程退出**不再等于会话消失**——它转成**只读保留态**,
|
|
137
|
+
**默认保留到显式关闭**(`EXITED_RETAIN_MS = ∞`;宿主 / 插件重启即清空),期间:
|
|
138
|
+
|
|
139
|
+
- **读得到**:`tty_capture`(尾部/`last`,含退出前的最后一批输出)、`tty_screen`(退出那一刻
|
|
140
|
+
的屏)、`tty_list`(条目带 `exited:true` + `exitCode`/`signal`)都照常可用,
|
|
141
|
+
结果里也带 `exited` 标记;`tty_expect` 不再空等——它按现存输出立刻结算并带 `exited:true`;
|
|
142
|
+
- **写不进去**:`tty_send` 明确报错(进程已经没了,写进去只会在死 PTY 上静默消失),
|
|
143
|
+
`tty_resize` / `tty_stats` 同样不再作用于它(stats 如实回 `available:false`);
|
|
144
|
+
- **不再接回终端**:`attach` 会被明确拒绝(没有活着的 PTY;面板也不会为它建标签),
|
|
145
|
+
输出用 `tty_capture` 读;
|
|
146
|
+
- **谁释放它**:agent 的 `tty_close`(仅限自己开的)、用户在面板里关那个标签、
|
|
147
|
+
或宿主 / 插件重启(保留是内存态)。并发上限**只数活着的会话**(保留态不占名额);
|
|
148
|
+
保留态本身最多留 `MAX_EXITED_SESSIONS`(16)条,超出按最旧淘汰(淘汰时
|
|
149
|
+
`logger.warn` 记下被挤掉的 sid)——也就是「最近的 16 条退出会话一直可读」,
|
|
150
|
+
这是内存与句柄的唯一上界(16 条约 ~20MB 量级)。
|
|
151
|
+
|
|
152
|
+
为什么**不用时间**淘汰:内存与句柄本来就不由时间决定(见上一句),而时间上界对
|
|
153
|
+
使用者是第二重惊喜——「命令跑完 → 下一轮读结果」之间隔着人离开、模型排队,多久
|
|
154
|
+
都有可能,一个到期就消失的输出比「要主动关掉」更难理解。想要时间上界(或干脆
|
|
155
|
+
不保留)把 `EXITED_RETAIN_MS` 改成毫秒数 / 0 即可:`reapExited` 那条通路还在,
|
|
156
|
+
回收器每轮都会调它;`tty_list` 的 `retainMs` 字段也只在按时间释放时才出现。
|
|
157
|
+
|
|
158
|
+
这就是「`tty_open` 跑一条会结束的命令、回头再取结果」能直接用的原因——不需要套一层
|
|
159
|
+
`sh`(issue #4 的诉求)。
|
|
160
|
+
|
|
128
161
|
### shell 集成(OSC 133/7,0.4.0)
|
|
129
162
|
|
|
130
163
|
spawn 时经既有的 `-c` 包装层按 shell 类型注入钩子(对用户透明,不改 rc):
|
|
@@ -149,8 +182,13 @@ spawn 时经既有的 `-c` 包装层按 shell 类型注入钩子(对用户透
|
|
|
149
182
|
与 `B` 之间,所以回显里的字样不会被当成命中(这里刻意不做文本比对:折行 / ANSI / 多行粘贴
|
|
150
183
|
都会把它打碎)。
|
|
151
184
|
|
|
152
|
-
- **没有 `B` 标记的环境**(未开集成 / Windows 本地标签 /
|
|
153
|
-
|
|
185
|
+
- **没有 `B` 标记的环境**(未开集成 / Windows 本地标签 / 远端未装集成)没有边界可切,D75 起
|
|
186
|
+
改用另一条判据剔除回显:把**最近 `tty_send` 提交过的命令行**从候选文本里削掉再试一次,
|
|
187
|
+
只有「削掉后不再命中」才算纯回显——这样「命令没执行、等待却自称 matched」这条假阳性没了,
|
|
188
|
+
而回显之后真的跑出来的同一段文本照旧命中。等满超时且 pattern 只命中过回显时,结果带
|
|
189
|
+
`echoOnly:true`,文案会直说「命令大概率没有被执行」。**边界**:只认 `tty_send` 写进去的行
|
|
190
|
+
(用户在面板里逐键敲的不做行编辑模拟),且命令正在跑(TUI 全屏重画里出现输入文本是真实
|
|
191
|
+
输出)时不启用剔除;
|
|
154
192
|
- **回看范围的取舍**:起点抬到「未读区内最后一个 `B` 标记之后」,代价是最后一条命令**开始
|
|
155
193
|
之前**的那段未读输出不在回看范围内。刻意选「宁可漏、不可误报」——把回显当成命中会让 AI
|
|
156
194
|
以为事件刚刚发生。要那段内容用 `tty_capture`。反过来的窄窗口也有一个:`tty_send` 之后、
|
|
@@ -631,12 +669,21 @@ ctx.inject(['ttyTerminal'], (c) => {
|
|
|
631
669
|
```
|
|
632
670
|
|
|
633
671
|
- 命令标签**不做 tmux 持久化**(命令短命,attach 无意义),也不走登录 shell;
|
|
634
|
-
SSH 侧用 `conn.exec(command, {pty})`,本地侧用
|
|
672
|
+
SSH 侧用 `conn.exec(command, {pty})`,本地侧用
|
|
673
|
+
`sh -c 'export TERM=…; exec <同一个 shell> -c <command>'`——外层 `exec` 让进程就是命令本身
|
|
674
|
+
(退出码与信号忠实),**内层 `-c` 才保证整段命令被当 shell 代码执行**:`cd x && cmd`、
|
|
675
|
+
`a; b`、`for …; do …; done` 都完整跑(D78:旧写法把命令直接缀在 `exec` 后面,只跑第一条,
|
|
676
|
+
内建命令还会直接结束外层 shell)。
|
|
635
677
|
- **命令标签会自动重开**:宿主重启 / 断线重连后 sid 已失效,客户端对
|
|
636
678
|
`spawnSpec.command` 的标签按原规格重新执行命令(普通非持久标签维持「点击重试」
|
|
637
679
|
的旧行为)。页面刷新后同样按原命令恢复。
|
|
638
680
|
- 命令来自**宿主侧插件**(不是远程用户输入),信任级与插件本身相同;tty 只校验
|
|
639
681
|
形状:非空、单行、长度 ≤2000(换行会破坏本地 `-c` 包装层)。
|
|
682
|
+
- **「单行 ≤2000」是这条通道的约束,不是 `tty_open` 的**(2026-09-27 复核指出):
|
|
683
|
+
`spawn` / `ssh` 帧的 `command` 要拼进 `-c` 包装层,所以由 `sanitizeCommand` 拒绝
|
|
684
|
+
换行与 NUL;而 agent 的 `tty_open.command` 走另一条路——命令**整段**用
|
|
685
|
+
`shSingleQuote` 包住交给内层 `-c`(见上一条),**可以含换行**(多行脚本实测可跑),
|
|
686
|
+
只受工具参数的字符串约束。两条通道的入参规则不同,别把这里当 `tty_open` 的限制。
|
|
640
687
|
- 服务名 `ttyTerminal` 同样未声明在 `Context` 类型面上,按可选依赖注入;tty 未安装
|
|
641
688
|
或版本 < 0.14.0 时不会触发(dsh-docker 会退化为「复制命令」)。
|
|
642
689
|
|
|
@@ -762,7 +809,7 @@ ctx.inject(['ttyPanel'], (c) => {
|
|
|
762
809
|
| C→S | `{t:'attach', sid}` | 重连孤儿会话(保活窗口内):`ready(reattached:true)` 后紧跟一帧 `data` 回放输出缓冲 |
|
|
763
810
|
| C→S | `{t:'statsOn' \| 'statsOff', sid}` | 订阅/退订该会话的服务器状态条(0.17.0):按标签可见性驱动,宿主只在有订阅时采集(懒启动 + 退订即停表并关远端 channel) |
|
|
764
811
|
| S→C | `{t:'ready', sid, pid, kind, target?, persist?, reattached?}` | 会话就绪;`kind:'local'` 带 pid,`kind:'ssh'` 时 pid=null、target=user@host[:port];attach 复用此帧并带 `reattached:true`;`persist:true` 表示 tmux 持久会话(0.10.0) |
|
|
765
|
-
| S→C | `{t:'data', sid, d}` | 终端输出(utf8 文本,StringDecoder 兜跨帧多字节序列);**12ms 窗口/64KB 阈值合并成帧**(0.4.1),exit/kill
|
|
812
|
+
| S→C | `{t:'data', sid, d}` | 终端输出(utf8 文本,StringDecoder 兜跨帧多字节序列);**12ms 窗口/64KB 阈值合并成帧**(0.4.1),exit/kill 前强制冲刷保证帧序——**退出前最后一批一定发出**(D76:这一批曾被终局路径自己的 `closed` 守卫整批吞掉,于「打印完就退出」的进程上表现为面板永远少最后几行) |
|
|
766
813
|
| S→C | `{t:'stats', sid, stats}` | 资源指标帧(0.17.0):`{cpuPct, cores, memUsed, memTotal, memPct, diskUsed, diskTotal, diskPct, uptimeSec, tcpConns, rxRate, txRate, tempC?}`;缺失字段即省略(best-effort,前端显示「无」),字节类为 bytes、速率为 B/s |
|
|
767
814
|
| S→C | `{t:'exit', sid, code, signal}` | PTY 退出事实(恰好一次;attach 换连接后仍随当前连接送达) |
|
|
768
815
|
| S→C | `{t:'error', sid?, m}` | 错误 |
|