DeepSeek Harness Plugin

hyzyn/dsh-plugin-kit#tty

Stars ★ 47 Downloads (30d) 6,795 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);
  • 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 toolbar offers clear / copy selection / 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;
  • 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, clicking the terminal area reopens it;
  • 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;
  • 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;
  • Three things are not supported (the host turns them off and the settings card explains why):
    • Shell integration (OSC 133/7): injection relies on POSIX rc stubs plus the -c wrapper, neither of which exists for cmd / PowerShell, so it is permanently off — local tabs therefore lose cwd tracking (tty_list.cwd following cd), tty_capture{last} and command-granular tty_expect (remote Linux / macOS hosts are unaffected);
    • 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). 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 seventeen tools into the agent (with the same power as the bash tool; operations show up live in the user’s terminal):

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 local 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 local 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)

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)

At spawn time hooks are injected through the existing -c wrapper layer according to the shell type (transparent to the user, no rc changes):

  • 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 tabs / a remote host without the hooks) 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;
  • 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).
  • Editing (0.20.0): “Edit” on a row loads it back into the form below and the button pair becomes “Save changes / Cancel”. Saving locates the entry by its original name — a tunnel name is derived (<book>-L<localPort>), so changing the port is changing the name and the new name cannot find the old row; enabled keeps its previous value (editing a spec never silently enables a stopped tunnel); the row being edited gets a left accent bar. A name clash is reported explicitly (no more silent -2 suffix, which used to spawn a second tunnel differing only by a trailing number).
  • A failed local listen is sticky: when the port is taken (EADDRINUSE) the state stays red error with the reason kept, and is not rewritten to “connecting” by the SSH-side retry (fixed in 0.20.0). Recover by changing the port (with the editor) or freeing it. ⚠️ ~/.dsh/settings.yaml is shared across profiles: with several profiles on one machine the same tunnel gets started by each of them and they fight over the same local port — a configuration conflict, not a plugin bug.
  • TOFU shares the same hostKeys pinning as terminal sessions; ports do not consume maxSessions slots.

SFTP file transfer (0.7.0, enhanced in 0.8.0/0.9.0)

Remote file operations go straight over the SSH connection without touching the terminal or using a session slot (ssh2’s sftp subsystem, host half in src/sftp.ts):

  • Placement (0.16.0): when the terminal panel is open and this tab’s mount slot is free, the File Browser docks below the terminal — the path bar / list / transfer progress take the full width (a file list is a wide table, so full width below beats a narrow column on the right, and the terminal also keeps its width without wrapping; a dual pane side by side needs that width even more), and the terminal stays visible and usable. The height is draggable and can collapse into a single title bar (collapsing neither closes the panel nor interrupts browsing); when another panel (such as the containers panel) already holds the slot on the same tab, or the panel is not open, it falls back to the original centered dialog, without pushing anyone else’s panel out. The title / collapse / ✕ come from tty’s mount slot;
  • Follows the tab (0.19.0): the File Browser talks to the host of the tab that opened it, so it belongs to that tab: switching away hides it (in-flight transfers keep running and the scene is restored when you come back), and closing the tab tears it down. Every entry follows the same rule — the connection bar’s “SFTP”, the 📂 on a connection-book entry and “File Browser” in the SSH dialog / settings card all become owned by whatever tab was active when they were opened; only “panel open with no tabs at all” counts as owned by no tab (always visible). Before this, the pane stayed put across a tab switch — title reading host A while the active tab was B, worst case uploading to the wrong host;
  • Entries: ① connection-book entries in the tab bar “+” menu carry a 📂 (open the File Browser for that entry); ② fill in host/authentication in the SSH connection dialog and click “File Browser” (you can browse without saving to the connection book); ③ the “SFTP” button in an SSH tab’s connection bar (owned by that tab, see the previous bullet);
  • Operations: directory browsing (Enter in the path box to jump, .. (parent directory), a single click on a file downloads it), upload (multi-select files, XHR streaming + percentage progress; since 0.8.0 drag & drop is supported — files and folders can be dropped straight into the dialog, folders are expanded recursively through webkitGetAsEntry and uploaded one by one, and directories are filled in level by level with mkdir parents), download (POST → browser Blob → <a download>), new directory, rename (inline editor), delete (🗑 with a second click to confirm, directories deleted with recursive);
  • Transfer progress bar (0.11.0): a thin progress bar + percentage was added on the right of the bottom status line — uploads use XHR streaming progress (multi-file shows an i/n · filename label); downloads now stream response.body and compute the percentage live from the response content-length (with no length it degrades to a transferred-bytes text);
  • Cancelling a transfer (0.12.0): the ✕ on the right of the progress bar — upload aborts the in-flight XHR (remaining files in the batch are skipped too); download uses AbortController to break off the streaming read; dual-pane ⇨/⇦ direct transfer became a server-side job (start returns a jobId → 400ms polling of real byte progress → ✕ sends cancel to abort), no longer an unbreakable synchronous HTTP request. After a cancel the half-written file is deleted automatically (the leftover remote file of an upload / the leftover local file of a download; a failed cleanup is only logged), the status line reads “cancelled” instead of a red failure state; closing the File Browser dialog also collects in-flight transfers, leaving no background copying that is “invisible but still writing to the remote”;
  • Connection management: a lazy connection pool — the SSH connection is created on the first operation, recycled after 120 seconds idle, and reconnected automatically on the next operation after a drop; connection-book entries are resolved live on every (re)connect (a changed password takes effect automatically); TOFU shares the same hostKeys pinning with terminal sessions/tunnels, and a changed fingerprint is rejected the same way; SFTP does not count against maxSessions;
  • Transfer channel: POST /api/dsh-tty/sftp/list|mkdir|rename|remove|download| upload (all loopback-fenced). The connection spec travels in the JSON body (connection-book name or inline fields, with the same semantics as WS ssh frames: “entry as the base + inline per-field overrides”) or, for upload, in the x-dsh-sftp-meta header (base64url) — credentials never enter the URL/query string; uploads and downloads are streamed pipes, and a whole file never enters memory;
  • Agent tools: sftp_list / sftp_read / sftp_write / sftp_mkdir / sftp_rename / sftp_remove / sftp_tree (see the table above) — they accept only a book connection-book entry name and take no inline credentials (agent context never carries plaintext secrets);
  • Dual-pane style (0.9.0, optional, sftpStyle configuration): local left / remote right — browsing and file operations on the local side go through the new /api/dsh-tty/local-fs route (list/mkdir/rename/ remove, loopback-fenced); the inline ⇨ / ⇦ copies an entry to the current directory of the opposite pane (/api/dsh-tty/local-fs/transfer streams the two paths server-side, recursing into directories and overwriting same-named files, with bytes never passing through the browser); the single-pane style is unchanged; switch it in the settings card and reopen SFTP for it to take effect.

Session persistence (tmux, 0.10.0)

The default safety model is unchanged: sessions live and die with the host (kernel-level PTY cleanup). For work that must “outlive the host” (dev servers, builds, training jobs), turn on a persistent terminal — session state is delegated to a tmux server (dedicated socket dsh-tty, fully isolated from the user’s own tmux), so it survives the keep-alive timeout and can even be reattached after a host restart:

  • Entry (simplified in 0.10.1): choosing tmux for “Session persistence” in the settings card is the only switch — once it is on, every newly opened tab is persistent by default: “Local terminal” in the “+” menu, clicking a connection-book entry, and the SSH connection dialog (“persistent session” is checked by default and can be unchecked for a single connection). There is no longer a separate “persistent terminal” menu item;
  • Per-entry opt-out (sshHosts[].persist): the “persistent session” checkbox on a connection-book entry stores an opt-out — entries explicitly unchecked (persist: false) are no longer tmux-backed when clicked, everything else (true or never written, e.g. entries imported from ~/.ssh/config) follows the global switch. Both the settings card and the connection dialog show that checkbox only while the global switch is on, and editing an entry in the settings card no longer drops it (before 0.17.x, renaming a single field silently reset it to false);
  • Mechanism: spawn/ssh frames carry persist plus a stable persistName generated by the client and saved with the tab spec — locally the -c wrapper layer becomes exec tmux -L dsh-tty -f <conf> new-session -A -s dsh-<name> (cwd is inherited from the node-pty spawn); over SSH the remote runs exec tmux -L dsh-tty -f /dev/null new-session -A -s dsh-<name> to open the pty channel. -A is attach-or-create: after a host restart, reopening a tab reattaches to the same tmux session by name, restoring running programs and pane state exactly;
  • Recovery chain: after the browser reconnects it queries sessions — persistent tabs whose sid is gone are respawned automatically by the client with the original persistName (non-persistent tabs keep the drop semantics); when the keep-alive reaper times out it kills only the PTY (the tmux client) and not the tmux session, so it can still be reattached afterwards; a reconnect (attach) within the same host does not replay the host buffer for a tmux session — replaying would first write the visible screen into a brand-new xterm (ghost scrollbar) and then the tmux full-screen redraw would paint it again (double image) — instead it forces exactly one tmux refresh-client redraw; persistent tab specs are also written to localStorage (sessionStorage is visible only to the same browser tab, so a new window that dsh opens automatically after a restart could not read it and recovery broke exactly there) — when a brand-new window opens the panel it respawns straight from the spec: if the tmux session is alive it reattaches to the original scene, and if it is gone (remote reinstall/lost) it starts a new shell; specs are only dropped when “the tab is actively closed/exited”, with no up-front liveness check (once the retained state such a check relies on drifts, recovery would fail silently);
  • Close semantics: for a tmux-backed session the kill frame (tab ✕ / closing the panel) runs tmux kill-session before killing the client — a real end, not a detach that leaves a live session behind; closing the whole page retains by default (recoverable after the keep-alive window), while `endOnPageClose: tru

…

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.