Saltar al contenido principal
N

dsh-shell-surfaces

nesarf/dsh-shell-surfaces

One DSH tool that runs a command on any execution surface the machine has -- WSL, MSYS2, Git Bash, PowerShell, nushell, interpreters, throwaway containers, ssh, real terminal windows and a real console -- and reports the version each one gives and the capabilities it has.

Instalar

dsh plugin --profile web add github:nesarf/dsh-shell-surfaces

README

dsh-shell-surfaces

Dessa Sophia

test licence: MIT

English | 简体中文

https://github.com/Nesarf/dsh-shell-surfaces -- a DSH plugin.

dsh plugin --profile <profile> add github:Nesarf/dsh-shell-surfaces          # the tip of main
dsh plugin --profile <profile> add github:Nesarf/dsh-shell-surfaces#v1.0.0  # a tag

The tag is the version that can be named. Installing from main gets whatever it holds today; installing a tag gets the commit that was decided on -- which is what "install the same thing again tomorrow" and "go back to what worked" both need. v1.0.0 is the first such point: thirty-five faces, two views of the same rows, and sizes a reader can drag.

The badge is the whole test workflow: the pure self-test, and a live check that runs one real command on every surface the runner has -- on Windows and on Linux -- then feeds those answers into the same table a person reads. A green badge means "the table and the argv shape still hold, and both platforms still answered", not just "the linter passed".

One DSH tool for the execution surfaces this machine has -- thirty-five faces at the last measurement: a WSL Linux userland and the ten shells inside it, the MSYS2 userland's six, four interpreters, both PowerShells, four window hosts, two container runtimes, a remote destination, a real console, and nushell.

It exists because a rule that is only written down is not a capability. The selection rules for these surfaces have been recorded for a while, but a session could only act on them by composing a command string for pwsh and hoping the quoting survived the trip. Here they are callable.

shell_surfaces(action="surfaces")                    what exists here, and where each was looked for
shell_surfaces(action="run", surface="wsl",  command="grep -P '(?<=Linux )[0-9.]+' /proc/version")
shell_surfaces(action="run", surface="nu",   command="open data.json | where n > 3 | to json")
shell_surfaces(action="run", surface="msys", command="pacman -Q | wc -l")
shell_surfaces(action="run", surface="pwsh", command="Get-CimInstance Win32_OperatingSystem | Select-Object Caption")
shell_surfaces(action="run", surface="pty",  command="powershell -File install.ps1", send=["y\n"])

Contents

The file is long because each section answers a question a person actually asked while using this, and the answer is not one sentence. The map below is the short version: read the section you need, skip the rest.

What you want to knowWhere
is PowerShell still the fallback?PowerShell is a surface here
can a command outlive the call?Background work stays here too
does confinement apply here?The same sandbox as the built-in shell tools
can state survive between calls?Persistent sessions
what else is installed on this machine?Finding this machine's shells
which version is each shell?Which version
what can each face actually do?What each face can do
run Python, Perl, JS, VBScriptInterpreters are faces too
reach another machineAn ssh destination is a surface
run it in a containerA container is a face too
what does the list look like?The list is a tree
which faces work together?Who works with whom
why one tool instead of many?Why one tool and not seven
what does a call render as?The row a call renders as
a surface refuses to answerOpening a path instead of refusing it
what is deliberately not supported?What it is not
why are the arguments shaped that way?The facts behind the argument lists
how do I run the checks?Verification
what is still unverified?Honest boundaries

PowerShell is a surface here

"Fall back to pwsh" used to mean "leave this tool and use the other one". The pwsh surface closes that gap: a PowerShell command is an ordinary call here, the same shape as every other surface, so a session that hits an awkward surface does not have to change its tool to get a PowerShell job done. Background jobs, timeout promotion, escalation approval and persistent sessions work on it as well -- they are surface features here, not privileges of one tool.

Both PowerShells are addressable, and that took two entries rather than one. pwsh is PowerShell 7 and is the better default; powershell is the 5.1 that ships with Windows. Keeping them behind a single DSH_PWSH variable meant a machine holding both could reach only one of them, while "does this script still work on 5.1" is a question two surfaces answer and one variable cannot.

The one shape this tool does not carry is a terminal session that keeps a drawn prompt or a cursor. That is where the built-in pwsh tool still earns its place; "the command is PowerShell" is not a reason to go there.

A surface-level failure is also not a reason to change tools. When a surface is absent, cannot spawn, or times out, the tool says so and names the next call (action="surfaces") that stays inside this plugin; the failure is about the surface, not about the tool.

Background work stays here too

run_in_background: true starts the command as a job and returns its id: the output ring streams it, job_output reads it, job_kill stops it. No timeout applies. promote_on_timeout: true turns a timeout into that same job instead of killing the process -- the output collected before the budget expired is handed over exactly once, and the rest keeps streaming.

That is why the built-in pwsh tool now covers only approval/sandbox escalation and the persistent session: the job machinery no longer sends a session back to it. A composition without the jobs service gets a refusal sentence for a background request rather than a job nobody can collect, and the pty surface has no background mode at all, because a console transcript is read while the program is alive.

The same sandbox as the built-in shell tools

A confined deployment confines this tool too. Each call resolves the session's policy through ctx.sandboxPolicy, wraps the exact argv it is about to spawn through ctx.sandbox.confine, and reports the mode, enforcement and denial facts in the same vocabulary the built-in shell tools use. danger-full-access never consults the provider, and a composition without the sandbox services runs exactly as before.

A file effect the mode denies is a policy answer, not a command failure. sandbox_permissions plus justification asks ctx.approval for exactly one wider call -- the grant is consumed by that call and never persisted -- and an escalation that is refused, cancelled or has no approval channel stops before anything runs. The same-turn denial hint rides the tool result, so the sanctioned retry does not depend on recalling the description.

Persistent sessions

Pass session: <name> to run a command inside one long-lived shell per (agent, surface, name): the current directory, variables and modules persist across calls, and session_reset: true starts a fresh one. The session is a process with a pipe, not a terminal -- a random nonce line marks where one command's output ends, which is the only signal every supported shell prints without a prompt.

Ten surfaces carry a session, in three shapes:

  • pwsh, msys, git-bash, zsh and tcsh keep exe + a flag list (-Command - / -l -s / -s), which is the default shape.
  • dash, brush and busybox have no login flag to give (dash is the strict POSIX sh by design, brush has no mount table, and BusyBox is ash), so their session is -s under the PATH the table sets -- busybox sh -s for the last.
  • wsl needs the distribution on its command line (wsl.exe -d Nyarch -- bash -l -s), so its entry builds the argv itself through argvFor. Measured 2026-10-24: cd /tmp && export DSS_PROBE=41 then echo "$DSS_PROBE:$(pwd)" answered 41:/tmp -- the directory and the variable both survived.

fish needed a different shape rather than a refusal. It treats a piped stdin as a script file and waits for EOF before running any of it -- measured 2026-10-24, with -l and with -l -i alike, a session that writes one command at a time got silence. The opening is to keep stdin away from the script parser: the session argv runs fish's own line reader, while read -l dss_line; eval $dss_line; end, so each line is evaluated on its own. Measured: cd /tmp and set -x DSS_X 41 survived into the next call as X=41;P=/tmp.

A session is a real process, and three things close one: the sweep before each session call (reapIdleSessions, thirty minutes of idle by default, DSH_SESSION_IDLE_MS to change it), a timer that sweeps the same budget on its own clock so a shell nobody returns to is still noticed, and the disposal of the conversation that owned it -- session/disposed closes the shells of every agent that named that session. Plugin unload closes whatever is left. A call that closed something says so in its message, because the next call to that shell starting fresh is otherwise a surprise.

A derived wsl-<shell> face deliberately has no session: the session's argv is bash -l -s, so a session there would quietly run bash while the row claims elvish or ysh. A session runs one command at a time, so the job flags are refused with it, and pty has no session at all: a console transcript is read while the program is alive.

Finding this machine's shells

The table is a set of defaults, and the scan is what makes them true on a machine that moved its installs. The model is borrowed from mega-index-map's library_detect: a built-in candidate table (shell names, the usual install roots, the WSL launcher) resolved against PATH and this machine's own locations, with every hit carrying where it was found. A scan costs one PATH listing per directory, a few stat calls, one wsl -l -q, and one command -v inside each distribution -- it is not a walk of the drive.

What the scan adds to the table:

FoundBecomes
a WSL distribution other than the defaultits own wsl-<distro> surface
a Git-for-Windows or Cygwin basha git-bash surface (the MSYS2 one keeps msys)
cmd.exea cmd surface (cmd /c)
a terminal program that can be driven (wt, mintty, wezterm, tabby)a face of its own -- through the dss-exec shim for the first two, its own pane API for WezTerm, a private profile for Tabby (see "Opening a path instead of refusing it")
a terminal program with no such entry (alacritty, conhost, kitty without a GUI session)reported in discovered with an openWith line saying how it could be reached, if at all
a terminal installed inside a distribution (kitty, tmux, screen)reported with wsl:<distro> as its source; the Windows PATH cannot see it
tcsh or BusyBox in the MSYS2 userlandsurfaces of their own: tcsh (-c) and busybox (sh -c)
dash in the MSYS2 userlanda dash surface: the strict POSIX sh, where a bash-ism fails instead of being forgiven
a shell installed inside a distribution (elvish, ksh, xonsh, mksh, tcsh, BusyBox)its own wsl-<shell> surface, which runs that shell rather than bash
oils-for-unix in a distributiontwo faces, wsl-osh and wsl-ysh: the bash-compatible half and the strict half that rejects what bash forgives

A shell that lives inside a distribution used to be reachable only by writing bash -lc 'elvish -c ...' by hand -- a wrapper the caller had to quote correctly every time, for a shell the scan had already found. wsl-elvish (and its siblings) run the shell itself: the command is written to the distribution's stdin as exec elvish -c '<command>', so the outer bash gets one word it passes through untouched. wsl-bash does not exist on purpose -- wsl already is bash.

Which version

The table answers "is it here?" and "where is it?". action="versions" answers the third question: which version. Every present face that opted in with a versionCommand is asked in its own language, and the answers are cached beside the harness home (shell-surfaces-versions.json, or DSH_VERSIONS_FILE). refresh: true asks again; nothing else re-probes, because the cost is one process per face.

Measured on this machine (2026-10-25): 20 of 35 surfaces answered, and no error came back dressed as a version. The three that needed more than --version each failed in its own way first, which is why they are written down here:

  • dash has no --version and no version variable. It is left blank.
  • mksh answers -c 'echo $KSH_VERSION' instead.
  • the MSYS2 busybox face runs busybox.exe sh -c, so inside it there is no busybox on PATH to ask. The WSL one runs where busybox is on PATH, and answers.

A face that cannot tell its version is left out rather than guessed at: wt --version opens a window, and an interpreter face would receive --version inside its -c, where it is a syntax error rather than a flag.

What each face can do

action="surfaces" carries a capabilities object per face, and the text prints it as a caps: note under each row. Six booleans, each answering a question a caller can act on instead of a question about the tool's nature:

capabilitythe question it answers
sessioncan a command keep state between two calls?
stdincan the command arrive on standard input (the session machinery needs it)?
interactivedoes it need a real terminal -- a program that reads a prompt?
guidoes it open a window?
pathTranslationdoes /tmp/x become a Windows path?
throwawayis every call a fresh environment?

They are derived from kind, session and tags in one function, so a face added later inherits the right answers instead of forgetting them. The one exception is data rather than a special case in code: brush is the shell inside the MSYS2 userland that refuses the path translation its siblings perform, and its tag says so.

Measured on this machine: ten faces keep a session, thirty take stdin, seven translate paths, four open a window, two are throwaway, and exactly one needs a real terminal (pty). Those numbers are assertions in test/surfaces-test.mjs, not a description -- a table that grew a second interactive face would fail the test before it could mislead anyone reading it.

The command travels on stdin, not as an argument

wsl.exe recomposes the words it was handed into one command line and hands that line to the distribution, where it is parsed once more. A $VAR in an argument was therefore expanded by a shell that had never run the loop defining it. Measured 2026-10-20, through this surface:

for n in alpha beta; do echo "n=[$n]"; done   ->  n=[] and n=[]   (wanted alpha, beta)
echo 'single=[$HOME]'                         ->  single=[/home/...]  (single quotes lost too)

while the same commands on the msys surface answered correctly. Base64 behind eval did not help -- that wrapper was re-parsed the same way. The one channel wsl.exe does not rewrite is stdin, so the command now goes there and bash reads it with -l -s. Scripts are unaffected: a file path was always safe.

The distribution probe is bounded: a cold instance that outlasts the budget reports nothing for that distribution instead of blocking the scan.

action="surfaces" answers with both halves: the surfaces this tool can run now, and the discovered report (kind, name, path, source) the scan produced. A discovered path also replaces a guessed default, so a machine whose PowerShell lives outside Program Files still answers.

refresh: true re-runs the scan on a surfaces query, so a shell installed since start-up appears without a restart. The surface argument is an open string for the same reason: the scan decides the ids, and the executor validates one against the scan it is holding rather than against a list fixed at registration.

Interpreters are faces too

python, node and perl are surfaces of their own rather than something to spell out inside a pwsh call. They pass the same test every shell here passes: they take a command and answer with stdout and an exit code. Their kind is interpreter, and the guide says what makes them different -- the code travels after -c (Python), -e (Node), or -e (Perl), and a pipeline belongs inside the code rather than around it.

Perl gets one environment entry of its own: PERL_BADLANG=0. Measured on this machine, a Perl run under the UTF-8 request warns about a locale it cannot honour before printing anything, and that warning arrives on stderr on every single call. The variable silences exactly that warning and changes nothing else.

cscript is the one interpreter whose input is a file: cscript //nologo takes a .vbs path and has no -c. That is what inlineFile exists for -- the command is written to a temporary .vbs in the spill directory, cscript runs it, and the file is removed the moment the run settles. Measured: the code never appears in argv, and the spill directory does not grow across calls.

An ssh destination is a surface

The host list comes from the user's own ~/.ssh/config, and ssh <host> <command> is already the "flags then command" argv shape every shell surface uses, so an ssh destination needs no new machinery: each host becomes an ssh-<host> face whose flags end with the host and whose command is the last argument.

Two kinds of entry are dropped on purpose, because a face that fails on every call is worse than no face:

  • a host with User git -- that speaks the git protocol, not a shell, and the far side refuses anything else. This machine's config carries github.com with User git, and it is dropped for exactly that reason.
  • a wildcard (Host *.internal) -- that is a pattern for matching, not a destination that can be dialled.

BatchMode=yes is not a preference. Without it a host that wants a password sits at a prompt nobody can type into, and the call reads as hung; with it, that host fails at once and says so.

StrictHostKeyChecking=accept-new is the other half of that argument. A host seen for the first time is recorded; a host whose key has changed is still refused. Without it the first call to any new destination dies with "Host key verification failed", which reads as a broken face rather than as an unrecorded host -- measured 2026-10-24 by clearing the entry from known_hosts and calling the surface.

When ssh cannot get as far as the command it answers 255, and the result carries a note saying so: an unreachable host, a refused key or a changed host key is a surface-level problem, not a failed command. Measured 2026-10-24 by stopping the daemon and calling the surface (exit=255, Connection refused, note present); with the daemon up the same call answers and the note is absent.

Standing up a loopback destination, so the face can be tested at all

This machine's ssh family was empty -- its only config entry is git-only -- and a face nobody can call is a claim, not a capability. Nothing here needs a second machine. Measured on this one (2026-10-24) with the distribution's own openssh 10.3p1:

# inside the distribution, as root
ssh-keygen -A                         # this image had no host keys at all
mkdir -p /run/sshd
/usr/sbin/sshd -p 2222 -o ListenAddress=127.0.0.1 \
               -o PubkeyAuthentication=yes -o PasswordAuthentication=no

then one dedicated keypair on the Windows side, its public half in the distribution user's ~/.ssh/authorized_keys (700 / 600), and this in ~/.ssh/config:

Host dss-loopback
  HostName 127.0.0.1
  Port 2222
  User nyarch
  IdentityFile ~/.ssh/id_ed25519_loopback
  IdentitiesOnly yes

action="surfaces" then reports ssh-dss-loopback [remote], and a command through it answers in 367 ms. The daemon binds loopback only and accepts keys only.

It can outlive a distribution restart, which is worth doing: a face that disappears on reboot fails the first time anyone needs it. The image runs systemd (/etc/wsl.conf sets systemd=true) and sshd_config already includes /etc/ssh/sshd_config.d/, so systemctl enable --now sshd is enough -- the drop-in above supplies the port and the key-only policy. Measured 2026-10-24 by wsl --shutdown and waking the distribution again: 15 s cold start, then ssh-after-restart with up 0 minute, active, enabled.

A trap worth writing down: generating that key with -N '""' in PowerShell makes the two quote characters the passphrase. The symptom is not "wrong passphrase" -- the server says Server accepts key and then the client never sends the signature (we did not send a packet), because BatchMode cannot ask. ssh-keygen -y -P '' is the check: it prints the public key when there is none.

rbash is scanned and reported but given no face: a restricted shell refuses the first cd, PATH assignment or redirection, so a surface that looks like bash and then declines to do bash things would mislead. Shells that are merely not installed -- yash, posh, oksh -- are already in the candidate table; install one and the next scan reports it, and promoting it to a face is a two-line change.

A container is a face too

Every container runtime the scan finds inside a distribution gets one: <runtime> run --rm <image> sh -c '<command>', handed over on stdin the same way a derived shell's command is. It is deliberately throwaway -- a face is one command, not a session -- so nothing persists between calls; when state matters, wsl is the surface to use. A machine with neither runtime reports neither face.

Both runtimes are present on this machine (2026-10-24) and they are not interchangeable: podman is rootless, docker is not. DSH_PODMAN_IMAGE and DSH_DOCKER_IMAGE (or one DSH_CONTAINER_IMAGE) pick the image, default docker.io/library/alpine:latest, pulled on the first call.

Getting them to run took four unrelated fixes, none of them the runtimes' fault:

  • libphonenumber pinned an older protobuf than podman wanted. Upgrading the two together resolved it -- the repository's current libphonenumber already depends on the new protobuf.
  • the pacman keyring was a 32-byte empty pubring.kbx, and every install failed with keyring is not writable. pacman-key --init plus --populate archlinux rebuilt it with 180 keys. Note that pkill -f gpg-agent is the wrong way to quiet the agent: the pattern matches the shell running it, which kills the command itself. The keyring went bad again after wsl --shutdown, so the rebuild is a thing to expect rather than a one-off.
  • rootless podman needs libsubid.so.6, which the installed shadow (4.18.0) did not provide; that package was upgraded for it. With subuid/subgid already in place the container ran first try: container-face-ok and ID=alpine in 2.6 s.
  • docker could not pull: dockerd uses the distribution's own network stack and the registry resolved to an IPv6 address that does not answer here (dial tcp [2a03:2880:...]:443: i/o timeout). The image came over from podman instead -- podman save docker.io/library/alpine:latest | docker load -- and docker run --rm alpine:latest then answered docker-ok. Adding the daemon to the docker group is what lets the surface reach the socket at all.

A container that is already running is a face of its own

<runtime> exec <name> sh -c '<command>', derived from docker ps / podman ps in the same scan, so the face exists exactly while its container does. It needs a shell inside the image -- a scratch image has none, and the call says so rather than pretending.

The difference is measured, not asserted (2026-10-24, writing a file through both doors and reading it on the next call):

facewriteread on the next call
docker-dss-docker -- exec into a running containerkeptkept
docker -- one-shot run --rmkeptmarker-gone

Getting the container list cost one lesson: the scan script goes on stdin, not as an argument. With it as an argument the list came back empty while docker ps answered fine by hand -- wsl.exe recomposes what it is handed and parses it again inside the distribution, which is the same trap the wsl surface's own commands hit.

The list is a tree

action="surfaces" draws the table as a tree, because a userland that hosts several shells is one place with children:

wsl [userland] available
    · a whole Linux userland (WSL2, default distribution Nyarch): the real grep -P, ...
    · when: Use when the job needs something only Linux has: ...
    wsl-zsh [shell] available
        · zsh inside the Nyarch distribution, as a surface of its own
        · when: Use when the command is written for zsh itself: ...
    wsl-dash [shell] available
        · dash inside the Nyarch distribution, as a surface of its own
        · when: ...
msys [userland] available
    · the MSYS2 userland bash: a Unix userland on Windows, with pacman for its own packages
    · when: ...
    zsh [shell] available
        · zsh inside MSYS2: the same userland as msys, a different shell language

The rule is the one a tree listing follows, and it is worth stating because a flat list says nothing about who owns what:

  • a face is printed at its own depth, four spaces per level;
  • its children are printed next, one level in, each at that same depth;
  • when the last child is done, the next face starts back at its own depth -- msys above begins at the left edge because it is a root, not because it is the next line;
  • the two lines explaining a face sit one level in and are led by a middle dot (· summary, · when:). A note carries no [kind], which is what tells the two apart at the same indentation.

The relation is derived (family / parent on every row) rather than written into each entry, so a row added later cannot forget who it hangs under.

The text above is what a reader and a model see. The client draws a treemap instead, and a listing is always open -- it is a map of the machine rather than a log to peek at.

The layout is done by flexbox rather than by arithmetic in the plugin: one vertical band per kind, its width a flex value proportional to how many faces the kind holds, and one cell per face inside the band. Weights are equal because a face has no size of its own -- counting faces is the only honest weight here. That choice is deliberate: a coordinate layout computed once would be right for one window width and wrong for every other, while a layout engine that already exists and already handles resizing is the better tool.

Grouping is family first: a face that owns children becomes a band of its own, holding itself and its children, so the map shows the same ownership the tree does. A lone face falls back to its kind, which is the most useful thing left to say about it. Family bands are drawn heavier than kind bands because they are a different claim. An absent face is dimmed and struck through rather than hidden.

Three things the picture can do that the text cannot:

  • pick a cell. At 320 pixels for thirty-five cells the map can only show names, so a click opens a detail block under it with that face's summary and its full when:. A tooltip cannot stay open while a paragraph is read.
  • say what is not a face. The scan finds programs that never become rows -- conhost.exe, tmux, screen, zellij, abduco, kitty, gnome-terminal on this machine. They get a dashed band of their own: putting them among the faces would make the map lie, and dropping them would withhold the answer a reader most often needs next. The text lists only the ones with no face at all, using openWith as the discriminator (face: ... means the face exists; shim or session host means it does not).
  • draw the crossings. Seven edges and eleven nodes is a size where a drawing beats a list, so the collaborations are a two-column graph: the borrowing face on the left, the borrowed one on the right, one curve per edge, the reason on the curve's tooltip. The a — b: how text form stays in the host output, which is what a model and a copied answer get.

A fourth thing the picture says, once there is data for it: how much each face is used. Every run increments a counter (in shell-surfaces-usage.json beside the harness home, or at DSH_USAGE_FILE), the face line carries · 3 runs, and a cell is sized 1 + log2(1 + runs). The compression is not decoration -- a face run ten thousand times and one run ten should not differ a thousand-fold in area, or the map stops being readable at either end. Equal cells were the honest choice while nothing was known about use; they are a poor one once something is.

The count is taken after the call rather than before: a face that failed to spawn was still reached, and a command that failed is still a run of its face. A request that never got as far as a surface is not counted at all. A failure to write the file is swallowed -- the count exists to make a picture better, and a picture is not worth failing a command over.

Two views of the same rows

The table is the default. Six columns -- name, kind, status, runs, parent, summary -- with a header row that stays put while the body scrolls and re-sorts the rows when a column is clicked, zebra stripes, and numbers on the right in tabular figures. Thirty-five rows of six attributes is a table's job: columns line up, so comparing two faces is a glance instead of a re-read, and "which face runs most" is one click. Density owns the row padding here too, so compact / normal / roomy apply to both views.

The map is the second view, one click away. It says the one thing a column cannot say as quickly -- how much of the picture a face occupies -- and it is still the place where a family band shows that a userland owns its shells. Both views read the same parsed rows, so neither can drift from the text.

The box the picture sits in is the grip. Drag its corner and the treemap re-lays itself out at the new size -- row height and column width together, because the browser is doing the layout and nothing here computes a second one. It starts 380 px tall, will not go below 320 x 220 (under that the drawing stops being readable) or above 80 vh (over that it pushes the conversation off the screen), and scrolls while the treemap keeps a 760 px floor and the crossing graph 460 px.

Every other call keeps the click, because its output can be a screenful nobody wants by default.

Who works with whom

A tree can say who lives inside whom; it cannot say who works with whom. A window face is not a child of node, and a container runtime is not a child of wsl -- it drives a daemon that happens to run there. Those relations are recorded as edges (cooperates on a row) and printed as their own section, one line per edge:

    · crossings: faces that work together rather than live inside each other
        · wt — node: the answer comes back through scripts/dss-exec.mjs
        · wezterm — cmd: each pane runs a cmd /k command line
        · docker — wsl: the daemon runs inside the distribution; this face only drives it

Two reasons it is a section rather than a second tree. An edge can join any two faces at any depth -- a window at the top and an interpreter at the top, a container and a userland -- so a line drawn between two rows would have to depend on where those rows happened to land, and the same table could then be read two ways. And the tree's job is ownership: a line that reads like a parent link is the one mistake this section exists to avoid. node is not inside wt; it is used by it.

The client renders the section as notes, one per row, so it stays visually subordinate to the tree without pretending to be a child.

Why one tool and not seven

Every registered tool spends part of the context on its description. Thirty-five tools would say the same thing thirty-five times. The selection rules -- which surface for a structured pipe, which for a Linux-only tool, which for a program that waits for input -- live in this one description, and a session reads them exactly when it is choosing.

This tool is the first choice for running a command, not a fallback for "exceptional" cases: a job that needs a Linux tool, a POSIX pipeline, another shell language, a structured pipe, a real console -- or PowerShell itself -- belongs on its surface. One prompt section says that, placed ahead of the pwsh rule, because a tool description only speaks once the catalogue is already open. Wrapping another shell inside a pwsh command line stays the thing to avoid: the extra quoting layer is where paths get mangled.

The row a call renders as

A shell_surfaces call used to render as the wire tool name -- "工具调用 · shell_surfaces · wsl" -- which names the catalogue entry rather than the machine surface or the command. The plugin now ships a client half (lib/client.js) that registers a tool.call.toolview row for this tool, so a call reads as the sentence it is:

WSL · grep -P '(?<=Linux )[0-9.]+' /proc/version      完成 · 120ms
zsh · ls                                            1 · 12ms
conPTY · cmd /c ver                                  运行中
shells · 32 of 32 surfaces are on this machine       完成

The surface name is at the head, the activity beside it, the exit status at the end, and the captured output one click away. That is a client concern rather than a host result: the pending row must already name its surface before any output exists. Nothing patches a first-party package -- tool.call.toolview is the supported keyed seam, and the stock row remains the fallback for every other tool.

Opening a path instead of refusing it

A host that cannot answer is a problem with four shapes, and three of them have a physical fix -- a piece that runs beside the host rather than an argument that hopes the host cooperates.

ShapeOpeningMeasured
a window that keeps its output (Windows Terminal, mintty)the shim: the window runs node scripts/dss-exec.mjs, the shim runs the command and writes {exitCode, stdout, stderr} to a file, and this tool polls that filewt answered wt-ok in 53 ms and carried cmd /c exit 5 back as exit 5; mintty answered in 39 ms; both are faces now
a program with its own control channel (WezTerm)the pane: wezterm cli spawn gives a pane a command, get-text reads the screen backwezterm-ok in 225 ms, ver in 227 ms
a program that refuses everything else on purpose (git-shell)the channel: it accepts only git transport, so a pre-receive hook in a bare repository runs on push and its output rides back on the push reportgit-shell -c 'echo x' answers fatal: unrecognized command -- the refusal is the design, so the opening is another protocol, not a wrapper
a multiplexer (tmux, zellij, abduco)the session host: driven headless (new-session -d + capture-pane) rather than as a windowcapture-pane returned tmux-ok and rc=0 from a detached session

Two more openings, both measured 2026-10-24:

  • rbash refuses cd, PATH changes and redirection (rbash -c 'cd /tmp' answers cd: restricted), and a plain bash -c from a non-restricted shell restores all three -- the opening is a second shell, not a flag.
  • mshta starts the shim like any other launcher: an HTA page whose WScript.Shell.Run(..., 0, True) runs dss-exec.mjs with the child window hidden. Measured: the report came back with mshta-ok in 316 ms and mshta itself exited in 1.3 s -- it does not hang the caller the way wt --help did. (node still has to be on PATH; an HTA has no shell to inherit one from.)

Tabby needed a different opening. It takes Electron switches only -- no -e, no new-tab -- <program> -- but it does have tabby profile <name>, and a local profile carries options.command + options.args. Those are static, so the profile names a fixed agent (scripts/dss-tabby-agent.mjs) once and every call only rewrites the job file that agent reads. scripts/tabby-setup.mjs writes that profile into a private instance's own config (--user-data-dir), never into the user's: handleOpenProfile calls bringToFront() on every open, so driving someone's Tabby would yank their window forward on every call.

Two measured traps shaped it:

  • a bare command: node does not work -- Tabby starts the profile and the agent never runs, so the profile names the interpreter by absolute path;
  • cmd /c start /min is not a way to keep the window out of the way: start takes the quoted program path as its window TITLE and then tries to launch the next word as a file, which produced a "Windows cannot find 'profile'" dialog and hung the caller. The window is minimised after the fact instead, and after the answer arrives, because bringToFront() un-minimises it while the profile opens.
  • the window used to stay open after the answer, because Tabby keeps a tab whose process has exited. It now closes itself: once the result is in hand the host runs scripts/close-tabby-dss.ps1, and the script stops only instances whose command line carries this tool's own user-data-dir, so a Tabby the person opened is never touched. The agent asks for the same on its way out; the host's call is synchronous on purpose, because a detached helper did not reach the instance in a measured run.

Tabby.exe profile dss itself never returns (it blocks for the application's lifetime), so the launch is detached and the result file is the only thing waited on. Measured: the agent answers in about 45 ms, and the whole call -- including minimising the window -- finishes in about 4 s once the instance is warm.

action="surfaces" carries this per program: every discovered terminal comes back with an openWith line, so "reported but not a face" is a next step rather than a shrug.

What it is not

  • No message hooks. It registers a tool, a prompt section and a client row; the message stream is not touched, so it cannot be the plugin that writes a malformed message.
  • No terminal emulator. A terminal emulator draws text for a person and adds nothing a subprocess can use. The one surface that needs a console talks to ConPTY directly.
  • No window launcher that cannot answer. Windows Terminal and mintty are driven through the dss-exec shim, WezTerm through its own pane API, and Tabby through a private profile; each returns a structured result rather than a window's contents. A terminal program with no such entry stays a scan report, and the report says which entry it would need: wt --help measured as a window that never exits, and kitty needs a GUI session this machine cannot give it. git-shell was refused for its own reason: it rejects every command that is not a git upload/download (fatal: unrecognized command). A face that cannot answer is worse than a report that says so.

The facts behind the argument lists

Each of these was measured on this machine, and each shapes the argv:

FactConsequence
A command handed through a shell can be re-parsed twiceThe command travels as one argv element; a script file is preferred over an inline string
wsl.exe recomposes its arguments into one command line, which is parsed again inside the distributionthe command travels on stdin (bash -l -s); as an argument it lost $VAR values to a shell that had not defined them, and even single-quoted literals lost their value
MSYS2's /tmp follows TMPDIR, which this machine passes down as a Windows pathmsys / zsh / fish run as login shells, which normalises the environment
brush has no Cygwin runtime beside it, so /tmp is <drive>:\tmpbrush gets no login flag and no POSIX-path assumptions; its PATH needs usr\bin as well as ucrt64\bin, or every external command fails while the shell reports a healthy version
PtyProcess.read blocksReading happens on its own thread; a read loop that also decides when to stop can block the very check that would stop it
A console wants Enter, which is a carriage returnsend normalises a trailing newline (real, escaped, or literal) to CR, and a bare answer gets one added
wsl.exe sometimes prints the result and then declines to exitA timeout with output is reported differently from a timeout without it; wsl.exe --shutdown is the reset (measured: a cold instance answered in 58 s, the same call after a reset in 8 s, and warm in 0.13 s)
PowerShell parses what follows -Command as a command line, so a script path handed that way is a command, not a filea script gets -File, a command line gets -Command; both always run -NoProfile -NonInteractive
pwsh 7 and Windows PowerShell 5.1 both answer UTF-8 through a pipe (measured 2026-10-15)the runner's single UTF-8 decode needs no per-surface exception
The job registry accepts each chunk through job.append, not through a read sourcethe producer pushes bytes as they arrive, and a promoted process flushes what it already produced exactly once
SandboxProvider.confine wraps argv, not a command linethe surface's exact argv (program, flags, command) is what gets wrapped, so nothing re-parses it
An approval grants one call, not a sessiona wider mode is consumed by the call that asked; the next call starts from the standing policy
A shell reading a pipe prints no prompta random nonce line ends each session command's output; the command's own text can never be mistaken for the marker
A scan that walks the drive is the thing it must never becomethe candidate table is the whole search space: PATH, the usual install roots, and one wsl -l -q

Layout

PathWhat it is
lib/index.jsthe tool: one shell_surfaces (run, surfaces, background, promote, sandbox escalation, sessions) and the selection-rule prompt section
lib/sessions.jsthe persistent session pool: one marker-delimited command at a time, per (owner, surface, name)
lib/client.jsthe client half: the tool.call.toolview row that renders as <surface> · <activity>, in the module system's own bundle format
lib/surfaces.jsthe table: paths, arguments, environments, and the absent-reason for each surface
lib/discover.jsthe scan: shell names, install roots and the WSL listing, each hit with its source
lib/runner.jsargv construction, bounded output with spill, timeout classification
lib/usage.jshow often each face has run: one counter per id, written through a rename, and a write that fails is swallowed
scripts/pty_runner.pythe console: pywinpty, a reader thread, ANSI stripping, one JSON report
scripts/live-check.mjsruns one real command per surface through the plugin's own code path
scripts/probe-faces.mjsreads the surface TABLE and asks every present face what it is; a face added to the table is covered without being added here too
scripts/probe-all-shells.ps1a version census for the terminals and the long-standing shells, from a hand-written list; platform-shaped, one JSON object out
scripts/probe-posix.sh, scripts/probe-wsl.shthe POSIX and WSL probes, as files, because an inline command crosses quoting layers and a $() gets expanded by the wrong one
scripts/probe-table.mjsthe probe's JSON as a markdown table, also written to $GITHUB_STEP_SUMMARY
scripts/pty-gate.ps1the interactive fixture: a Read-Host prompt for the pty case
scripts/pty-gate.shthe same fixture for a POSIX console: read needs a terminal, which is the whole point of a pty
scripts/minimize-window.ps1does the one thing no terminal program offers a flag for: minimises the window a surface opened, matched by PID or by a per-call marker in its command line
scripts/which-surfaces.mjsone line per surface, present or absent with the path looked for -- readable on its own, and CI's first step when the table cannot even load
test/surfaces-test.mjsthe table, the argv shape, the tool contract, the prompt section
test/client-bundle-test.mjsthe bundle format, the keyed row registration, and the sentence the row says in every call phase

Verification

node test/surfaces-test.mjs          # no host needed; the host validator is asked too when reachable
node test/client-bundle-test.mjs     # the real client bundle behind a fake module loader
node scripts/live-check.mjs          # one real command per surface on this machine
node scripts/live-check.mjs wsl nu   # or only some of them
DSH_LIVE_TERMINALS=1 node scripts/live-check.mjs wt mintty wezterm tabby   # the window faces, which open windows
node scripts/probe-faces.mjs | node scripts/probe-table.mjs   # every surface in the table
pwsh -File scripts/probe-all-shells.ps1 | node scripts/probe-table.mjs   # terminals + the long-standing shells

live-check.mjs is the probe now. With LIVE_CHECK_JSON=1 it writes the JSON probe-table.mjs eats to stdout and its human-readable lines to stderr, so one run answers both "is this surface alive" and "what version is it". CI uses that form; the plain form is what a person runs.

probe-faces.mjs came first and does the census without asserting anything: it reads the table and asks every present face what it is. It stays for a quick "what is on this machine right now" that cannot fail a build.

probe-all-shells.ps1 stays for what the table cannot answer: the terminal programs without a face (alacritty, conhost, kitty here) still have no entry, and a version census is where their presence is recorded.

The live check covers the interpreters and the shells inside a distribution as well, and it opts out of the window faces by default: a routine run should not put four windows on someone's screen, so wt, mintty, wezterm and tabby are exercised only when DSH_LIVE_TERMINALS=1 or when named on the command line. The count is whatever this machine has, and the run says it: with the window faces included it answered for thirty-one surfaces and skipped one, whose nushell cannot run on that distribution's glibc. The four window faces answer wt-ok / mintty-ok / wezterm-ok / tabby-ok in 0.34 to 1.05 s once their instance is warm (tabby's first call pays for a cold start, and its window now closes itself afterwards).

CI runs the self-test, and runs the probe on both a Windows and a Linux runner (the tables land in the run summary, so "which surfaces exist" is a record with history rather than something a person measured once). Each runner also runs this live check against every surface it actually has.

That last step reports four outcomes, not two: pass, fail, absent and skipped. A surface a machine does not have is a fact about the machine (absent, with the path that was looked for); a surface that exists but cannot be exercised here -- a WSL launcher with no distribution, a python without pywinpty -- is also a fact about the machine (skipped, with the reason). A surface that ran and answered wrongly is a fact about the plugin, and only that fails the job, because a runner with nothing to run is not a broken build.

A pass also needs the expected output, not just exit code 0: bash -c "" exits 0, and the failures this check exists to catch (a command mangled by quoting, output decoded as the wrong encoding) are exactly the ones that keep exit code 0.

Its table goes into the run summary beside the probe's, so "this runner is missing half the surfaces, and here is why" is answerable without opening the log.

The first full pass (2026-10-15) ran every surface the table had then: wsl returned the kernel and a /proc count, nu returned a number through a structured pipeline, msys returned its bash version and a package count, zsh its parameter-expansion result, fish its arithmetic result, brush an uppercased string, and pty answered a Read-Host prompt with y and printed the branch it took. That list has since grown to thirty-five faces -- which is exactly why the live check reads the table instead of keeping a list of its own.

Honest boundaries

  • Windows-first. The surfaces it knows about are this class of machine's: a WSL distro, MSYS2, nushell, and ConPTY. On POSIX the platform already ships a single shell, so most of this is unnecessary there.
  • No isolation of its own: a command runs with the harness process's rights. The file sandbox and approval policy still apply to the process tree.
  • MSYS-family output is assumed UTF-8 (LANG=C.UTF-8 is set). A Windows program reached through a surface may still answer in the OEM code page.
  • The MSYS2 surfaces need DSH_MSYS_ROOT if MSYS2 is not at D:\msys64; nushell needs DSH_NU if nu.exe is not in %LOCALAPPDATA%\Programs\nu\bin; the pty surface needs python on PATH with pywinpty installed.
  • The pwsh surface is PowerShell 7 (pwsh.exe) by default; a machine with only Windows PowerShell sets DSH_PWSH=powershell.exe.
  • The pwsh surface can run in the foreground, as a background job (run_in_background), or promoted past its timeout (promote_on_timeout), and it carries escalation approval and a persistent session as well. The built-in pwsh tool keeps exactly one shape this one does not: a terminal session that keeps a drawn prompt or a cursor.
  • Background work and promotion need the jobs service. A composition without it gets a refusal sentence instead of a job nobody can read, and the pty surface has no background mode at all: a console transcript has to be read while the program is alive.
  • The sandbox seam is optional: without ctx.sandbox and ctx.sandboxPolicy nothing is confined, exactly as before. When it is mounted, confinement covers every surface, including wsl and msys; a surface the runner cannot launch under confinement reports the sandbox problem rather than running unconfined.
  • Sessions exist for ten surfaces -- the shells that can read commands from stdin, which is every shell here except pty and the four window hosts. A derived wsl-<shell> face has none on purpose (a session there would run bash while the row claims elvish), and a surface without one says so and names the ones that have it. A session is process-local: it does not survive a harness restart.
  • The scan runs once per process; refresh: true on a surfaces query re-runs it, so a shell installed later appears without a restart.
  • Of the terminal programs this machine has, four are faces (wt, mintty, wezterm, tabby) and the rest stay reports. pty remains the one real console, and the one shape no window face carries.

Plugins relacionados