DeepSeek Harness Plugin

hyzyn/dsh-plugin-kit#tty

Stars ★ 48 Downloads (30d) 6,969 Category UI Enhancements Added 2026-08-30 npm @hyzyn/dsh-tty

Terminal panel for the DSH Web GUI: a sidebar "Terminal" entry opens a multi-tab xterm.js interactive terminal over a real PTY (node-pty), with SSH remote tabs (ssh2, agent / key / password auth), a cwd following the current session, and tty_list / tty_capture / tty_send agent tools.

Install

# from npm (prebuilt)

dsh plugin --profile web add @hyzyn/dsh-tty

# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)

dsh plugin --profile web add github:hyzyn/dsh-plugin-kit#path:/packages/tty

Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).

README

中文 | English

The DSH sidebar “Terminal” panel: a complete terminal built on xterm.js + a real PTY, treating local and SSH alike, with sessions kept alive across disconnects for long-running work.

Features

  • Real PTY + WebGL rendering: node-pty real PTY, so TUIs such as vim / htop / a dev server all run; multi-tab.
  • Optional tmux session persistence: reopen after a host restart / network blip and the scene is back; “command tabs” such as docker exec reopen automatically.
  • Native ssh2 connections: agent forwarding + host-key TOFU pinning, managed uniformly through the connection book; plus SFTP upload/download and port forwarding (-L / -R, reconnecting automatically after a drop).
  • The agent reads the terminal at “command” granularity: shell integration (OSC 133/7) lets tty_capture{last} / tty_expect read the output and exit code of “the previous command” instead of capturing the screen and guessing; tty_list reports whether a command is currently running (running, three states: running / finished / cannot tell), and tty_run returns the output and exit code of one command in a single call.
  • Two client services exposed to other plugins: ttyConnbar (connection-bar actions) and ttyTerminal (open a terminal in place); dsh-docker’s “Containers / Terminal” buttons go through them.
  • AI assist: explain failures (0.24.0, off by default): when a command exits non-zero a chip appears in the corner; clicking it sends that command's output tail (cleaned / truncated / secrets masked) plus the current screen to the model and renders a short "what happened / next step". A suggested command is only typed into the prompt line — never submitted for you. See AI assist.

Installation

dsh plugin --profile web add @hyzyn/dsh-tty    # npm install (after release)
dsh plugin --profile web add link:$(pwd)/packages/tty   # repo development/debugging

After installing, restart dsh web; a “Terminal” entry appears in the sidebar — click it to open the panel; the Settings → Plugins → “Terminal Panel” card lets you change the configuration (saving takes effect immediately, no restart needed).

How to use

  • Opening the panel automatically creates the first terminal (default $SHELL, usually zsh on macOS);
  • Multi-tab: “+” in the tab bar creates a new terminal (since 0.2.0 “+” is a menu: local terminal / SSH connection book (entries have ✎ to edit) / SSH connection…, see the next section for SSH), and ✕ closes a tab; double-clicking a tab renames it (the name is persisted with the tab and survives a reconnect); each tab is an independent session (local PTY or SSH channel); when the tab bar runs out of room it scrolls horizontally (no scrollbar — wheel / trackpad, with fades at both edges) and a “⋯” tab list appears at the strip’s right edge listing only the tabs scrolled out of view (status dot + target, with the current tab highlighted when it is one of them): one click to switch, and an inline ✕ to close it right there (the list stays open so you can close several in a row);
  • Close a batch of tabs at once (0.30.0): no more clicking ✕ one by one — right-click any tab to get a menu anchored on it (Close this tab / Close other tabs (N) / Close tabs to the left (N) / Close tabs to the right (N) / Clean up exited tabs (N); rows with nothing to close are not shown — no “left” row when the anchored tab is first, no “right” row when it is last), and middle-click closes the tab under the pointer. The “⋯” menu carries the same batch rows (anchored on the current tab) and, since 0.30.0, appears whenever there are ≥ 2 tabs (overflow or dead tabs are no longer required): right-click is the fast path, but it must not be the only way in; with a single tab it still stays hidden (there is nothing to manage — that is the only real noise here). Actions that would end ≥ 2 live sessions ask once first (how many, and how many of those the AI opened); closing a single live session is the same as clicking its own ✕, so it does not ask (the rule lives in client-src/tab-bulk.js, same “what do you lose” criterion as the D98 panel-close guard);
  • “Hidden from the AI” tabs (0.30.0, off by default): the context menu gains “Hide this tab from the AI” — once hidden, the agent side cannot see it at all: it is absent from tty_list and from the per-turn systemPrompt session block (not even cwd or whether a command is running; that block only reports “N more tabs are set to be invisible to the agent”), and tty_capture / tty_screen / tty_send / tty_expect / tty_stats are refused for it by sid. The marker is a 🙈 before the tab title plus a tooltip (the “⋯” list shows it too). In your own panel the tab stays fully usable (view, type, close); enforcement happens on the host and applies to the agent only — keep one terminal of your own for keys, personal work, or anything unrelated to this turn without worrying about it being screen-scraped. Tabs the AI opened do not get this item (hiding them would erase its behaviour from your view); the rule lives in client-src/tab-hidden.js plus the host’s setHiddenFromAgent, and after a reconnect only “hidden” is re-asserted (the hidden state is never silently cleared);
  • The working directory follows the current DSH session: new tabs open in the current session’s working directory (the host cwd configuration is the fallback). Since 0.1.6 the session list snapshot no longer carries current (view selection moved to the workspace domain), so the client reads retainedBy.mainView > 0 to find the current session — trusting only the legacy field leaves the cwd empty and new tabs fall back to the host’s start directory;
  • Supports TUIs such as vim / htop / less (TERM is injected as xterm-256color);
  • Panel size changes are resized automatically (xterm fit → native PTY resize); inactive tabs do not follow — each keeps its own last valid size (D79: when the container is invisible or the probe is degenerate, no resize frame is sent at all);
  • Ctrl+F searches inside the terminal (Enter next / Shift+Enter previous / Esc closes only the search box), links in the output are clickable, and the header toolbar keeps just search / clear — copy and paste use the terminal’s own shortcuts (Cmd+C / Ctrl+Shift+C to copy a selection, Cmd+V / Ctrl+V to paste);
  • Reconnect on disconnect (0.3.0): after an abnormal disconnect such as a page refresh or a network blip, the session is kept alive on the host for reconnectGraceSec (120 seconds by default) and the client reconnects automatically with exponential backoff (capped at 5s); after reconnecting it attaches back to the original session by sid and replays the output buffered during the disconnect; after a page refresh the tab list is restored from sessionStorage (sessions already finished on the host are dropped);
  • WebGL renderer (0.3.0): high-throughput output (build logs) renders dramatically faster; on WebGL context loss (e.g. too many tabs exceeding the browser quota) it automatically falls back to the DOM renderer;
  • Minimize (state folded into the sidebar entry): clicking outside the modal, pressing Esc or the title-bar “—” collapses the panel — PTY sessions and output buffers stay alive, the sidebar “Terminal” entry shows a “running / total” badge and a status dot (pulsing when there is output), and clicking the entry restores it; only the floating bar’s ✕ / the title-bar ✕ really closes it and ends every session;
  • Closing the panel asks first (issue #8): while the panel still has live sessions, ✕ (including the one on the floating bar) opens a confirmation that states how many sessions this ends, how many of them the AI opened, and puts Minimize (sessions and output keep running) on the same button row — it used to only point at «—» on the title bar, which that very layer covers and swallows, so the way out was “cancel first, then go find «—»”; putting it in reach turns the misclick into the right action. With only exited tabs (or none) it closes straight away: the predicate is what would be lost, not how many tabs are open (a confirmation that fires too often gets clicked out of habit, and then the real one stops working);
  • Exited sessions no longer cover their output (issue #7①②): the exited state is now a strip at the top of the terminal area (one line — “Exited code=…” / “The AI finished this session” — plus at most one action button), so output stays readable, scrollable and selectable. The previous full-area overlay was both why “the AI’s command output is unreadable” (users hid .tt_overlay from devtools) and why clicking anywhere was swallowed — even selecting text was impossible. Error and “reconnecting” overlays stay: their job is to explain why there is no output;
  • “Clean up exited tabs (N)” in the “⋯” menu (issue #7③): removes every exited tab in one go (which also makes the host release their read-only retention right away); the “⋯” button now also appears when there are dead tabs without overflow — it used to show up only on overflow, while the AI leaves one dead tab per command (a user collected 47 of them and had to close them one by one);
  • Tabs opened by the AI follow their session (issue #7④): once the AI releases a session (tty_close, or tty_run’s default cleanup), its tab goes away with it — the one you switched to and are looking at gets one step of grace (a tab the client itself promoted to the foreground does not count) (the strip says “The AI finished this session” with a “Dismiss” button; switching away or clicking it closes the tab). Sessions still in read-only retention (tty_run keep:true, tty_open command= that was never closed) keep their tabs — readable, swept by the cleanup entry above;
  • The title-bar ✕ closes the panel and ends every session (PTY tree-level cleanup; tmux persistent tabs also get kill-session); after a session exits, reopen it from the top strip (a local tab opens a fresh session from its spec, persistent tabs reattach to tmux, command tabs run again);
  • The concurrency limit defaults to 4 (maxSessions configuration, 1~16).

Windows hosts (0.18.1, best-effort)

Before 0.18.1 a Windows host could not open a single local terminal, for two reasons on the same path:

  1. $SHELL does not exist on Windows, and the default shell fell back to /bin/zsh unconditionally → spawn fails with ENOENT (measured: spawn /bin/zsh -> ENOENT, while %COMSPEC% works);
  2. the spawn plan was POSIX-shaped (-c 'export TERM=…; export COLORTERM=…; exec "$shell"') — cmd.exe does not understand -c (it ignores the whole line, runs an empty session and exits, so the tab appears and vanishes), and PowerShell understands -c but rejects export as an unknown cmdlet.

Both were reproduced on Windows 11 ARM (24H2) + Node 22 ARM64. The fix:

  • Default shell is %COMSPEC% (guaranteed to exist); for PowerShell put the full path to powershell.exe / pwsh.exe in the settings card’s “Shell path”. On Windows the candidate list offers %COMSPEC% + Windows PowerShell 5.1 + PowerShell 7 (when installed), with the default first;
  • No wrapper layer on Windows: plain [shell]; the PowerShell family gets -NoLogo (drops the copyright banner) but deliberately not -NoProfile — the user’s profile is where aliases and functions come from. TERM / COLORTERM are meaningless for ConPTY and are no longer injected;
  • “Run one command” tabs (docker exec, command tabs opened by the agent) use cmd /c or PowerShell -Command; PowerShell’s -Command collapses a non-zero last command into process exit code 1 (cmd /c exit 5 → 1, both versions), so to get a concrete code let the command exit 5 itself (POSIX sh -c does not behave this way);
  • Input line-ending normalisation (D74, reported on real hardware 2026-09-27): conhost treats Enter as CR, so a bare LF only moves the cursor down and does not submit the command line — tty_send writing echo X\n as the tool description says leaves the command sitting on the input line (no output, no new prompt, looking like “it was sent but never ran”). For local sessions on win32 the plugin now turns a bare LF into CRLF (an existing CRLF is not doubled; non-Windows and SSH sessions pass through untouched), so the documented \n path just works;
  • Shell integration (OSC 133/7) depends on the shell family: the PowerShell family is supported (powershell.exe / pwsh.exe) through a stable -File stub that hooks prompt and PSConsoleHostReadLine — this yields cwd tracking (tty_list.cwd following cd), exit codes, the running state and command-granular tty_expect. cmd is not (it has no command-boundary hooks), so the settings card greys the switch out and points at “switch the shell path to PowerShell”. The two versions differ, and the syntax hint is branched by version: && / || are PowerShell 7 only — on 5.1 && is a parse error and not a single command runs. Known limit (not reproduced): it was recorded that on PowerShell 7 the command output reaches ConPTY after the D marker, so tty_capture{last} may come back empty; on 2026-10-13 this was re-measured on pwsh 7.6.6 (Win11 ARM64) across three output shapes (20 small outputs / a 2000-line burst / 20 writes over 2s) with 0/20 empty windows — not reproduced. If you do hit one, read the tail with tty_capture and report the pwsh version. One more exit-code rule: in the PowerShell family a parse error (&& on 5.1, echo (1 on both) runs nothing at all yet the D marker reports 0 — never judge success by the exit code alone; the error text is in the output. The stub only loads through -ExecutionPolicy Bypass (process-scoped); two boundaries are recorded as they are: when Group Policy locks the execution policy to Restricted / AllSigned that parameter does not override GPO, so the stub fails to load and the tab opens with integration silently absent (the same degradation as “the user turned PSReadLine off”); the parameter also writes the value into $Env:PSExecutionPolicyPreference, which PowerShell processes started from that session inherit. One more known side effect: wrapping prompt makes the user’s original prompt see its own $? as the result of our writes (always true), so failure indicators that rely on $? stop working;
  • Two things are not supported (the host turns them off and the settings card explains why):
    • tmux persistence: there is no tmux on Windows, so local tabs open normally but are not managed (SSH into a Linux host still works);
    • Server status bar: locally only CPU / memory / uptime are available (node:os); disk / TCP / network throughput / temperature show “无” (remote Windows hosts go through the PowerShell hop — see the status-bar section);
  • How far this was verified: on Windows 11 ARM a full install (dsh plugin add @hyzyn/dsh-all), all nine plugins mounting, dsh web serving, and the browser half being delivered (the same artifact macOS serves; size and new-code markers compared after a build rather than pinning a byte count, which changes on every rebuild), plus [dsh-tty] mounted (shell=C:\WINDOWS\system32\cmd.exe). x64 Windows is covered by the CI matrix (build / typecheck / test, see Development).
  • End-to-end coverage (0.19.0): scripts/windows-smoke.mjs drives a real cmd.exe through spawn → input echo → kill → respawn on the CI windows-latest runner, plus two links that only hold on a real machine (W6: after normalisation a bare LF really does submit in conhost, D74; W7: a command session that exits turns into read-only retention and tty_capture still reads its pre-exit output, D77). W8 (the PowerShell OSC 133/7 stage) runs on CI (since 2026-10-13): it used to be guarded by the real-machine lane only, because killing a ConPTY session there tripped 0xC0000374 — the root cause (the same PTY being natively kill()ed twice) is now located and fixed, see D100 in DEFECTS.md and the 2026-10-13 section of ROADMAP.md. Where no PowerShell exists the stage honestly reports its own SKIP row (excluded from the denominator, never dressed up as 12/12). It caught and now pins a Windows-only failure class: force-killing a local PTY crashed the host — node-pty rejects signals on Windows, and its _deferNoArgs re-throws that error from a socket callback, where the caller’s try/catch cannot see it.

Agent tools

The plugin injects nineteen tools into the agent (operations show up live in the user’s terminal; every call is subject to the session permission tier, see below):

Tool Purpose
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). running says whether a command is currently executing (true = it is: do not send into it now; false = the command has finished; omitted = cannot tell — non-persistent SSH / fish·csh / Windows cmd sessions carry no shell-integration markers, which is not the same as “idle”), and the same-source lastExitCode / lastExitAt are the exit code and end time of “the previous completed command” (0.23.0). The rendered text spells the three states [空闲] / [运行中——现在别往里发命令] / [命令状态未知——…]; “unknown” also covers one more case — the host has seen no marker for that session since this boot (typically a tmux persistent session already sitting at its prompt before the host restarted): send it a bare Enter or run a command and the marker arrives, turning it into [空闲]; non-persistent SSH / fish·csh / Windows cmd sessions never emit markers, so [命令状态未知] is their normal state
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 code in the host shell’s syntax — on a POSIX shell cd x && cmd, a; b and multi-line scripts work, while Windows cmd / PowerShell use their own syntax; the current shell and syntax are restated every turn in the systemPrompt terminal line; 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
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)
tty_run One-shot command (0.23.0): open a session, run command, wait for it to finish and return the tail output + exit code directly — collapsing “tty_open → wait → tty_capture{last} → tty_close” into a single call. It closes the session once the command finishes by default (the result is already in this call); keep:true leaves it in read-only retention instead. If it is still running at timeoutSec (1~600, default 120) it returns running:true and does not kill the session (whether to keep waiting or close it is the caller’s call). When to use this instead of the bash tool: when the user should see the command, or when it must run inside a terminal session; for purely non-interactive commands bash is more direct
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
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). A command-type session (tty_open command= / tty_run) has no “previous command” (it injects no shell-integration hooks), so last:true on one errors explicitly and points at lines (0.23.0, D87)
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
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)
tty_send Send text and/or keys to a given session (such as q to a dev server, a menu selection, or :wq in vim). Use the named keys for control and arrow keys (["C-c"], ["Down","Down","Enter"] — a whitelisted lookup: an unknown name errors out instead of being silently sent as a literal), and never hand-build escape sequences inside data — "\\x1b[B" / "^[[B" get printed into the terminal as ordinary characters while the sent count looks identical (0.23.0); give at least one of data/keys, and when both are present data goes first followed by keys in order. 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)
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)
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)
sftp_write Write a remote text file (overwrite by default, append:true appends; ≤1MB per call)
sftp_mkdir Create a remote directory; parents:true fills in missing parents level by level (equivalent to mkdir -p, created bottom-up, existing directories skipped idempotently)
sftp_rename Rename/move a remote file or directory (a to in a different directory means a move; never overwrites an existing target)
sftp_remove Delete a remote file/directory; a directory uses rmdir by default (a non-empty one errors explicitly), and recursive:true deletes the whole tree (irrecoverable); paths pointing at /, ~, or containing ./.. segments are refused (guard for an irrecoverable operation, 0.19.0)
sftp_tree Recursively list a remote directory structure (depth-first, directories first; maxDepth 18 / maxEntries 12000 cap it, truncated:true when exceeded; symlinks are not followed, to avoid cycles)
tunnel_list List port-forwarding tunnels and their live state (active/connecting/error/stopped, rules, connection counts)
tunnel_start / tunnel_stop Start or stop one port-forwarding tunnel (0.25.0; name comes from tunnel_list). These rewrite the configuration — the same thing as ticking/unticking “Enable” in the settings card (settings → hot apply → reconcile), so the change is persistent and visible to the user. The returned snapshot usually still says connecting (dialling is async); call tunnel_list again for the final verdict

Session permission tier gate (tier gate)

Tools are classified by risk; every call is judged against the session’s permission tier (Read Only / Workspace Write / Full access / Auto review) and allowed, asked, or denied — the tier is switched by the user in the session, and the very next call follows the new tier:

Class Tools Behaviour under a confined tier (Read Only / Workspace Write)
Read tty_list tty_capture tty_screen tty_expect tty_stats tunnel_list sftp_list sftp_read sftp_tree tty_close Allowed on every tier
Write (bounded mutation) sftp_write sftp_mkdir sftp_rename sftp_remove tunnel_stop Approval prompt (runs only if the user allows this one call); deterministically denied when unattended (approval policy never)
Execute (unbounded command surface) tty_open tty_run tty_send tunnel_start Same — these tools can run arbitrary commands on the local or a remote shell and open listening ports; they are the fence-sensitive surface

Full access never prompts (that tier’s intent is fewer confirmation steps); under Auto review the plugin does not add its own prompt (the host’s model pre-review already examines every call). When the host has not composed the sandboxPolicy / approval services (older hosts, minimal deployments) the gate is off (behaviour identical to before the gate), and the startup log says so: tier-gate: services absent (…), per-call gate disabled. The mechanism (decision matrix / the two-layer relationship with the static allowProxyCommand switch) lives in docs/permission-tier-plan.md.

Typical agent flow (recommended): for a command that simply runs to completion, use tty_run to get its output + exit code in one call; for a long-lived session, tty_open opens one (pass persistName for tmux persistence on long-running work) → tty_send starts the command → tty_expect waits for the readiness marker → tty_capture{last:true} gets the result of that single command → tty_close when done. In addition, a dynamic context is registered in systemPrompt so that every turn automatically carries a snapshot of active terminals (sid / kind / cwd / owner, plus a [running] marker) — you have context without calling tty_list first.

Agent-opened session boundaries (0.20.0): a session opened by tty_open is an ordinary tab in the panel (marked “agent”) — the user can see it, switch to it, take it over and close it. Hidden sessions are deliberately not used, because “the user does not know what is running on the machine” is exactly where zombie sessions come from. Such a session has no client bound from birth, so it is not reaped by the orphan collector (which only reaps disconnected user sessions); it ends via the agent’s tty_close or the user closing the tab. Conversely the agent cannot close a user-opened tab (tty_close refuses explicitly).

Read-only retention after the process exits (D77)

A process exit no longer means the session is gone — the session turns into a read-only retained one, kept until it is explicitly closed by default (EXITED_RETAIN_MS = ∞; a host/plugin restart clears them). While retained:

  • Reads keep working: tty_capture (tail or last, including the final batch of output printed before exit), tty_screen (the screen as of the exit) and tty_list (entry carries exited:true + exitCode/signal) all still answer, and their results carry an exited marker; tty_expect no longer burns its timeout — it settles against the existing output and returns exited:true;

  • Writes are refused: tty_send reports an explicit error (the process is gone; a write would vanish into a dead PTY), and tty_resize / tty_stats no longer apply to it (stats honestly returns available:false);

  • No re-attaching a terminal: attach is explicitly refused (there is no live PTY; the panel does not create a tab for it either) — read the output with tty_capture;

  • Who releases it: the agent’s tty_close (its own sessions only), the user closing that tab in the panel, or a host/plugin restart (retention lives in memory). The concurrency limit counts live sessions only (retained ones do not consume a slot) — and the client’s “+” pre-check counts the same way (D85: the sessions frame’s list includes retained sessions, so taking list.length as the live count made the client block the user’s own “+” with “session limit reached” even though there was no tab left to close). At most MAX_EXITED_SESSIONS (8) retained sessions are kept — the oldest is evicted beyond that (each eviction logs the sid it drops). So the rule is “the last 16 exited sessions stay readable”, which is the only bound on memory and handles (16 of them is on the order of ~20MB).

    Why not a timer: memory and handles are not actually time-driven (see above), while a deadline is a second surprise for whoever reads the output — the gap between “the command finished” and “the next round reads the result” spans people walking away and queued model turns. An output that vanishes on schedule is harder to reason about than one you have to close. If you do want a time bound (or no retention at all), set EXITED_RETAIN_MS to a millisecond value / 0 — the reapExited path is still wired into the reaper, and tty_list’s retainMs field only appears when a timer is in play.

This is why “tty_open a command that finishes and read the result afterwards” now just works — no sh wrapper needed (the request in issue #4).

Shell integration (OSC 133/7, 0.4.0)

On POSIX, hooks are injected through the existing -c wrapper layer according to the shell type; on Windows the PowerShell family gets a stable -File stub instead (see the Windows section above). Both are transparent to the user and change no rc:

  • zsh: ZDOTDIR points at a temporary stub directory (the same approach VS Code uses); the stub sources the user’s original rc first and then appends precmd/preexec hooks;
  • bash: an --rcfile stub (sources ~/.bashrc first); the command-start marker has two variants by version: bash ≥ 4.4 uses PS0; bash < 4.4 (the 3.2 shipped with macOS) has no PS0 and falls back to a DEBUG trap (the handler filters by $BASH_COMMAND to drop fires caused by the PROMPT_COMMAND machinery itself, so phantom markers do not cut the user’s output out of the capture window; on bash 3.2 trap - DEBUG inside the handler does not take effect, hence the “permanently armed + filtered” design). Side effect: internal commands of compound commands such as loops emit extra B markers, which only affects where tty_capture{last} starts capturing for those commands — D/exit-code and tty_expect are unaffected; the PROMPT_COMMAND hook supports both the string and the array (bash 5.1+) forms;
  • Marker semantics: 133;A prompt start / 133;B command start / 133;D;<exit> command end with exit code / OSC 7 file://… cwd reporting (tty_list.cwd follows cd, and SSH sessions report the remote path);
  • Other shells are silently disabled; shellIntegration: false turns the whole thing off (escape hatch).

Look-back matching and the read watermark (D72): tty_expect first looks back at output that has not been read yet (when a command finishes instantly, the marker you are waiting for is already in the buffer). The look-back lower bound is raised to “just after the last B marker” inside the unread region — the echo sits between A and B, so text in the echo never counts as a hit (deliberately no text comparison here: line wrapping / ANSI / multi-line paste would break it).

  • Environments without B markers (integration off / local Windows cmd tabs / a remote host without the hooks / PSReadLine turned off by the user) have no boundary to cut at, so since D75 the echo is excluded by a different test: the command lines that the most recent tty_send submitted are stripped out of the candidate text and the pattern is retried — only “no longer matches after stripping” counts as a pure echo. That removes the false positive where the command never ran yet the wait reported matched:true, while the same text really printed later still matches. When the wait times out and the pattern only ever matched the echo, the result carries echoOnly:true and the message says plainly that the command was probably never executed. Boundary: only lines written by tty_send are known (typing in the panel is not simulated as line editing), and stripping is disabled while a command is running (in a full-screen TUI redraw, the typed text appearing on screen is real output);
  • The tradeoff in that look-back range: raising the lower bound to “just after the last B marker” costs you the unread output that precedes the newest command’s start. It deliberately prefers a miss over a false hit — treating the echo as a match would make the agent believe the event just happened. Use tty_capture for that text. The mirror-image corner exists too: right after tty_send, before the new command’s B marker arrives, the unread region still holds the previous command’s output, so a pattern in it matches at once (the agent’s next call almost always lands after that B, where this text has already been cut off by it);
  • Read watermark: whatever tty_capture / tty_expect returns counts as read, and later tty_expect calls will not look back at it (use tty_capture to re-read older output). tty_screen deliberately does not advance it (the screen is a different representation, not a text stream); tty_send only initialises it (the output of the command just sent has not been seen yet); a timeout does not consume the unread region either (another pattern can still look back at the same output). A tab the user opened gets its watermark placed at the moment the agent first touches it — history before that does not count as unread.

SSH sessions are scheduled on the same table: entries with kind: 'ssh' in tty_list are identified by target (user@host[:port]), and tty_capture / tty_expect / tty_send are used exactly as for local sessions — dev server logs and key interactions on the remote machine remain available as usual.

Credential storage (a connection password need not stay plaintext)

Under Password in the connection dialog sits a credential storage row: tick "store when saving" and the password is written to the official credential store, leaving only an env:NAME reference in the field (the value lives in ~/.dsh/.credentials.yaml, never materialized into the environment and never sent back to the browser). That checkbox is ticked by default (whenever the host provides remote.credentials): type a plaintext password, save, and the value goes to the store while the settings keep only a reference — better than writing the password plaintext into the settings file, which is shipped to the browser while the store's values never are. When the host lacks that service the box is switched back off and disabled, with a note that only plaintext saving is available.

  • It rides along with saving — no extra click: the tick is executed by "save changes" / "connect (and save)", so there is no dangling reference from "stored but not saved". Without "save to the connection book" it never touches the store — there would be no configuration to reference the value, only an orphan. The settings card's inline editor is the same row with the same default, except that its commit entry point is "apply" (that card's row-level commit, followed by the card's "save"), so the checkbox reads "store into credential storage on apply"; the same caveat applies there: the value lands in the store first, and abandoning "save" afterwards leaves a name that is not referenced yet (visible and clearable in the reference picker).

  • Two side effects of the default (so neither is a surprise): (1) editing an older connection whose password is plaintext and hitting "save changes" for an unrelated field also moves the password into the store (the settings keep a reference instead) — untick the box if you do not want that; (2) when the store refuses the write (typically a read-only source shadowing the reference) saving is aborted and the official verbatim error is shown, rather than quietly falling back to plaintext (the derived name carries a DSH_TTY_ prefix plus a hash, so shadowing is practically impossible).

  • Deliberately compact: in plaintext mode the row is a single checkbox; only once the field holds a reference does it swap in "clear stored credential" and the status (already stored (source file) · reference name). The full security boundary lives in the checkbox's tooltip instead of taking up dialog space.

  • Model: configuration holds a reference, the store owns the value — the same family as SecureCRT's "credential set referenced by title" and iTerm2's "pick a named entry from the password manager". The implementation goes through DSH's official ctx.remote.credentials (describe / set / unset, where values cross in one direction only — no read path exists), exactly as the official settings cards do.

  • Name (the rule is fixed): DSH_TTY_<username>_<host>[_<port>]_<field>, where the port is omitted when empty or 22 (the default) — matching the connecting side's spec.port ?? 22 (empty means 22), so “empty / 22 / " 22 "” all collapse into one key and the same account never ends up with two names or one password stored twice; a non-default port does take part (the same host on another port is often a different box behind NAT). field is PASSWORD / PASSPHRASE; e.g. hsadmin@192.0.2.10:22 → DSH_TTY_HSADMIN_192_0_2_10_PASSWORD (the dots of a dotted IP fold to _). It is derived from the resource identity and carries no hash — the same school as git-credential-store's protocol://username@host and docker credential helpers' ServerURL + Username: host and username are ASCII identifiers already, so nothing needs sanitizing and nothing needs a hash to disambiguate. The old hash-based version was patching over "sanitize a human label into a key": the reference grammar accepts ASCII only, so web 01 / web_01 (space vs underscore) collapse to exactly the same string and only a hash could stop them silently overwriting each other. A resource identity has no such trap — a collision can only happen for the same host, the same user, the same port, which is the same password by definition (sharing it is correct behaviour). The connection name never takes part in the key, so renaming a connection or rewriting its label never changes the key. An empty host or username is refused (they are the key's entire source; drop either and the derivation degenerates into a constant). The trade-off is readability: this dialog's "store" names it by the rule above and offers no custom name ✗ — to reuse a custom name, type env:name into the field directly (the name must already resolve in the credential layer, e.g. managed by the official settings or env card).

  • Derivation happens only at store time: afterwards the env:NAME in the configuration is the single source of truth and nothing re-derives it — so renaming a connection does not invalidate a stored value and no longer leaves an orphan (only the old hash-based rule did: with the name in the key, storing again after a rename left the previous name behind; clear it with "clear stored credential", which acts on the reference in the field).

  • Shared: references live in one flat namespace, so any consumer resolving the same way can use it — put the same name in a dsh-docker target's password and one secret serves both.

  • Visibility: the dialog's reference picker lists the names the store already holds — the host-side /api/dsh-tty/credential-refs reads back only the refs: keys (names only; values never leave the host). Why read it ourselves: the reference half is not enumerable over the protocol (rationale in the picker section), yet “which names have I stored” is exactly the question this picker answers. It also makes orphan references (old names left behind by a naming-rule change) visible, selectable and clearable again.

  • Resolution path (what makes a stored value actually usable when connecting): connecting, the probe, SFTP and port tunnels all funnel through one buildConnectConfig, whose env:NAME is resolved by the official credential provider (resolve, re-resolved per operation — a change lands on the next operation, no host restart). Only when the provider has no such reference, or the host has no such service, does it fall back to process.env. Why the provider is mandatory here: values in the credential store are never materialized into the environment (the provider README's own words: “a store the harness owns and never materializes into the environment”), so reading process.env alone means “a stored password is unreadable at connect time”. A provider error is never swallowed — when the environment lacks it too, the error names both sources (otherwise “the credential service is broken” masquerades as “you did not configure it”).

  • The conservative boundary (know this): ~/.credentials.yaml is a 0600 plain file with no master password and no OS keychain — it keeps out other OS users, not processes running as you, and not the agent; and it is machine-local, so a new machine means storing again. The industry consensus still stands: prefer keys / agent over stored passwords (this plugin supports both).

  • Degradation: without remote.credentials (older host) the buttons disable with a reason and plaintext saving still works.

Port forwarding (0.5.0)

Maintain tunnels in the “Port forwarding” block of the Settings → Plugins → Terminal Panel card; each tunnel references one connection-book entry (host and authentication come with it), in two directions:

  • Local forwarding (-L): listen on local 127.0.0.1:localPort → connect through SSH on the server side to remoteHost:remotePort — maps a remote database/internal service to the local machine (the most frequent use: localPort=5432 → db.internal:5432);
  • Remote forwarding (-R): the server listens on remoteHost:remotePort (default 127.0.0.1) → inbound connections are dialed back to local localTargetHost:localTargetPort — exposes a local dev server to a remote network/intranet;
  • The host owns the lifecycle: tunnels and terminal tabs are independent of each other (each has its own SSH connection), and tunnels keep running with the panel closed; SSH disconnects reconnect automatically with exponential backoff (1s→15s cap), and the remote direction re-runs forwardIn after a reconnect; after the connection-book password changes, a reconnect uses the new credentials automatically;
  • Profiles running side by side must offset their localPort: port forwarding is a machine-level resource, while the configuration is stored per profile (copying a profile copies its tunnels too). Two profiles running the same tunnel at the same time leave the later one with EADDRINUSE, stuck in error with fatal:true (no retry; changing the configuration rebuilds it from the new spec). The error message names “possibly the host process of another DSH profile” and offers two ways out;
  • Ports are probed at save time (0.26.0): saving the tunnel configuration first probes each local port (an exclusive probe: listen, then release immediately) so two classes of problem surface at the moment you save instead of later — ① the configuration being submitted has two local tunnels on the same localPort (decidable at configuration level), and ② the port is held by something outside this process (another profile's host, or another program). The probe only warns; it never blocks the save: the 200 response carries an extra warnings array and the card shows it in the yellow warning slot (which replaces the green “is active” — that tunnel will sit in red error and never retry, so “is active” would read as everything being fine). Why not reject: the card saves the whole table at once, so rejecting would lock a user whose config already contains a conflict out of saving anything — and fixing it is exactly what requires saving first. Two rules worth remembering: a port this process already holds is exempt (editing a running tunnel's other fields leaves its port held by itself — that is not a conflict, since reconcile stops then starts and the port returns in the same tick), while a disabled tunnel still counts as a duplicate (it can be enabled at any moment). A remote direction's remotePort is listened on the server side and is not probed locally;
  • Status badges: while the card is expanded it polls live status every 2s (active green/connecting blue/error red/stopped grey + last error); connection-book entries in the “+” menu show a ⇄N tunnel badge; the agent can query status with the tunnel_list tool;
  • Connbar entry (0.20.0): a connection with enabled tunnels gets a permanent “隧道 N” button (click for live status); one without them folds into a “⋯ More” menu — previously such a connection gave no hint at all that port forwarding existed, so the user had to stumble onto the settings card. Folding it into “⋯” adds a discovery path without occupying permanent width (the connbar gives its width to the tab strip first). Note a tunnel must reference a connection-book entry, so only book-backed connections get this entry; an unsaved ad-hoc connection from the “new connection” dialog cannot have tunnels.
  • Form layout (0.20.0): direction uses a segmented control (local -L / remote -R — two values do not deserve a dropdown); the two ends are shown in pairs as host:port, with an arrow between them showing the data flow (it flips when you switch to -R). Each direction has exactly one fixed end (the host hardcodes it) and that end renders as static text rather than an input — an input you cannot change would be a lie:
    • -L: the left end is fixed at 127.0.0.1 (bound locally, not exposed to the LAN); the right end takes the SSH-server-side target;
    • -R: the left end takes the SSH-server-side listen address; the right end is fixed at 127.0.0.1 (the local service being reached). Two styling rules (from a user screenshot: “输入框的样式优化一下”): the group hugs its content instead of stretching with the row (otherwise the left group leaves a dead zone after the port box, while the right group's host soaks up all the slack and pushes the colon and port to

…

Content from the project README on GitHub ↗

Links

More in this category

View the whole category →

Community comments

Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.