dsh-at-terminal
Verified@at-series/dsh-at-terminal ยท v0.3.0 ยท Web UI
AT Terminal for DeepSeek Harness: SSH/SFTP assets and remote SSH terminals.
Install
dsh plugin add @at-series/dsh-at-terminal Confirm the layer applied with dsh --profile default --dump-config โ see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Creators
Readme
AT Terminal for DeepSeek Harness
This directory holds the dsh bundle for AT Terminal โ the third build
variant beside base and mcp. It is a normal npm package that DeepSeek
Harness loads as a plugin; it does not touch the VS Code extension code
path, and building or installing it must never change the base / mcp
variants.
- Verified against: DeepSeek Harness Desktop
0.2.1-rc.5(the version this machine has installed; the tool DSL is re-checked against that install on everyverifyrun, and the sandbox is re-extracted whenever the install changes โ a cached sandbox from a previous release is discarded, not reused). - Current milestone: M3/M4 โ the Host half carries the asset, server and
terminal services over the
webServerchannel, and the browser half is a built bundle reusing the VS Code variant's xterm terminal and server-form frontend.CAPABILITIESintools.jsremains the single source of truth for what the tool surface exposes.
Layout
| Path | Role |
|---|---|
package.json |
Bundle manifest: exports, dsh.bundle.patch, dsh.client, icon, shipped files |
cordis.patch.yml |
The patch dsh applies when the bundle is selected; inserts the at-terminal row |
index.js |
Host half โ exports Config, inject and apply(ctx, config); composes tools, the platform assembly and the channel. The assembly installs itself into the shared platform registry (src/platform/current.ts) the domain layer reads through โ the dsh counterpart of setPlatform in src/extension.ts, and the call whose absence made every terminal connect fail with platform not installed until a real session found it |
channel.js |
HostโClient channel over ctx.webServer: unary RPC, SSE events, the byte-stream gauge and the terminal WebSocket (DSH-21/25). Mutating routes run a Host/Origin/Sec-Fetch-Site/content-type fence copied from the Harness's own dsh-client-connection; refusals are counted in state.get().rejected and logged with the request's own host/origin (never its body) |
tools.js |
Tool definitions as plain data, already normalized for ctx.tools.register(). No @deepseek-ai/* import, so it loads without a dsh checkout |
policy.js |
The classification table, the ctx.tools.guard and the tools/pre-execute stage. Every tool needs a row (an unclassified one is denied), sensitive-path deletes are refused outright, and command authorization is delegated to authorizeCommand() so the gate and the dialog cannot disagree. Imports nothing: the predicate and the authorizer are injected |
settings.js |
Settings schema, constraints and defaults. Also free of @deepseek-ai/* imports; the schema factory takes the schemastery instance as an argument |
commands.js |
The two Host-side slash commands (/ssh-metrics, /ssh-audit) and the registration helper. commands.register() throws on a duplicate name, so the helper treats "already registered" as a skip โ a reload applies the same bundle twice. Handlers never throw: they answer with an error result, because the host turns a thrown handler into a command/done failure and the person gets a stack trace where a sentence would do |
prompt.js |
The guidance section and the one variable it interpolates. It says once what the tool descriptions used to repeat โ the three tool families, what each trust tier means, the read-before-write order, and how to read a spillPath. ctx.inject(['systemPrompt']) rather than a required inject entry: a composition without dsh-system-prompt should lose the guidance, not the plugin |
skills.js |
Three runtime skills โ inspection, remote edit, incident write-up โ registered on ctx.skills through ctx.inject, with the same "absent service costs the catalog, not the plugin" rule as the section. register() answers a duplicate with a warning and a no-op disposer rather than throwing, so the count it returns is not evidence: verifyRegisteredSkills() reads the catalog back and ping reports that. In this desktop build tool-skill is disabled at the base layer and enabled again for preset-run agents, which is why the model-facing catalog is a separate question from registration |
spill.js |
Puts a result too big to show into the Host's ctx.spillStore and keeps the head, the tail and the account of what fell between them inline. The store is read per call, so a preset that composes it later still works; its absence degrades to truncation, never to an error |
jobs.js |
The job producer for run_remote_command: it hands ctx.jobs.start() a spec whose cancel signals the remote process group. pidfileFor() and backgroundScript() come from host.bundle.js, so the pidfile naming and the pid write have one definition each. The two at_terminal_job_* tools in tools.js are the matching consumers: the desktop composition ships tool-jobs disabled (dsh-web-app/cordis.patch.yml), which leaves the registry running and the model with no way to read or stop what it started |
host.bundle.js |
Built (committed): the TypeScript domain services (src/platform/dsh/entry.ts) compiled for the Host half |
client.js |
Built (committed): the browser half โ src/platform/dsh/client/** bundled with xterm and the reused webview assets into one ModuleLoader factory |
esbuild.dsh.mjs |
Builds both artifacts; --check (wired into verify:dsh) fails when either is stale |
locale/ |
Display title and description for the Plugin Manager card |
node_modules/ |
Vendored runtime dependencies (@deepseek-ai/schemastery, @deepseek-ai/cosmokit), committed so a link:-installed bundle resolves them. Regenerate with npm run vendor:dsh |
scripts/verify.mjs |
The gate (see below) |
scripts/vendor.mjs |
Downloads and verifies the vendored dependencies against an exact version + sha512 lock |
scripts/enable-mcp.mjs |
Idempotently writes the AT Series Hub MCP row into the dsh profile (M1) |
scripts/probe-hub.mjs |
Talks MCP directly to the Hub with the same command/env as the profile row |
scripts/probe-channel.mjs |
Asks the running Host whether the channel routes and the browser half are alive |
scripts/lib/asar.mjs |
Reads the installed Harness app.asar to obtain the real @deepseek-ai/* packages |
Verify
node dsh/scripts/verify.mjs
Eleven independent checks; a non-zero exit fails the gate:
- syntax โ every shipped JavaScript file parses.
- manifest โ the documented bundle contract (name/version/
exports/patch path/icon rules/locale), plus runtime files actually listed infiles. - patch โ
cordis.patch.ymlparses as YAML and really inserts this bundle's row with the expected id. - deps โ every module specifier in a shipped file is statically scanned and
must be a
node:builtin, a relative path, or a package from the vendoring allowlist โ and that package must really be present undernode_modules/. Any@deepseek-ai/dsh-*specifier fails, with file, line and specifier printed. - tools โ every tool definition satisfies the installed
ToolRuntime.register()contract:parametersis exactly the normalizedparameterSchemaSpecToJsonSchema({})({ type: 'object', properties: {} }, not{}),output.schemais already raw JSON Schema (valueSchemaSpecToJsonSchemais the identity on it),execute()'s value is accepted by the realvalidateJsonSchemaValue, and the renderer is exercised. It also pins the background-job contract: no tool may take a name@deepseek-ai/dsh-tool-jobsalready registers,run_in_backgroundmust be a boolean parameter, a call without a job registry must degrade to foreground with anotice, and an accepted call must handstart()the agent id asowner(a session id does not resolve throughagents.get()) and reach the service's remote kill on cancel. The bytes the host receives are asserted too, not just the spec: the command must begin with the pid write, because a version that named the pidfile and killed it but never wrote it passed every "start() was called" test on the shelf and failed on the first real host. - settings โ the exported
Configschema is built with the installed@deepseek-ai/schemastery. It must describe exactly the six documented fields, resolve the documented defaults through real.get()accessors, mark every field.volatile()after a JSON round-trip (the projectiondsh-settingsperforms), and reject eleven illegal values โ both on direct validation and through~standard.validate, the path dsh uses when persisting a settings edit. Boundary values must stay accepted. - client โ the browser half.
dsh.clientmust be declared and well-formed,exports["./client"]must resolve to a file that is also listed infiles, the loader call'sidmust equal the package name, and no statement other than the loader call may run at script execution. The factory contract is asserted by executing the built file in a sandbox: the module scope must evaluate with nothing but awindow.__ModuleLoader__stub,navigatorandself(xterm's platform detection reads those at init;documentstays absent so any DOM touch at load time throws), and the factory must hand back a plugin withapply()and aninjectlist namingslots,sidebarRightTabsandlocale, withreactas the only external it requires. All of these failure modes are silent in a browser โ a bundle registered under the wrong name is simply never required โ so they are asserted here rather than reviewed. - bundle โ both committed artifacts (
host.bundle.jsandclient.js) are rebuilt in memory and compared byte for byte: alink:-installed package runs no build step, so a stale artifact is the easiest way to ship code nobody wrote. - theme-tokens โ the
--vscode-*โ--dsw-alias-*bridge carries only theme references (andcolor-mix(...)that resolves through one), and the whole stylesheet is scanned forrgb()/hsl()forms a#rrggbbscan cannot see. A literal does not follow the light/dark switch, so it is a silent theme bug. - policy โ every tool
createTools()registers has a row indsh/policy.js, and every row names a registered tool. The runtime guard denies an unclassified tool, so a missing row is an outage while a stale row is a table that lies; both directions are asserted, and the row that keepssftp_deleteoff sensitive paths is pinned by flag.
Check 4 is static and always runs. Checks 5 and 6 extract the real
@deepseek-ai/dsh-tools and @deepseek-ai/schemastery dependency closures out of
/Applications/DeepSeek Harness.app/Contents/Resources/app.asar into
dsh/.cache/dsh-sandbox (git-ignored). Point DSH_ASAR at another archive to
verify against a different install. If no installation is found the check is
reported as SKIP, never as PASS.
Useful flags:
node dsh/scripts/verify.mjs --no-sandbox # checks 1โ4, 7 and 8 (5โ6 need the installed Harness)
node dsh/scripts/verify.mjs --json # machine-readable report
Where the domain-layer audit lives
An earlier revision of this file carried a seventh check that swept src/{ssh,sftp,โฆ}
for direct vscode imports. It has been removed, not because the rule went away but
because the rule is a property of the repository's kernel, not of this bundle โ and
having two allowlists for one rule already caused a real drift (the list still named four
files when a fifth adapter had been added).
That audit now lives at the repository level:
npm run verify:kernel # scripts/verify-kernel-seam.mjs, 4 checks
It is strictly wider than the check it replaced: the old one scanned the seven domain
directories against a hard-coded four-file list, while the new one scans every .ts
file under src/. Its four checks:
src/core/**imports nothing above itself โ and no source file cites apath:linewhose file this repository does not contain (the port layer was derived from the VS Code variant, so a dozen citations namedsrc/extension.ts:456; the names stay as evidence, the dead line numbers were dropped).- No
src/**file importsvscode, unless the importing line carries a documentedkernel-allow:reason. - No
src/**file imports@deepseek-ai/*. Only import statements count: the tree has fourteen comments naming those packages to explain why they are not imported, and a string scan would fail on the explanation. - The dsh composition root (
src/platform/dsh/index.ts, which buildscreateDshPlatform) callssetPlatform(). The VS Code root issrc/extension.tsin another repository, so it is not โ and cannot be โ in scope here.
Specifiers are read with the TypeScript compiler's own preProcessFile, not a regex, so a
comment or a template string cannot be mistaken for an import. Each check has been
negative-tested: a stray import '../ssh/SshSession' and a dead src/extension.ts:12
citation in src/core, an import * as vscode in src/utils/**, an
import '@deepseek-ai/dsh-tools' in src/utils/**, and a commented-out setPlatform()
call each fail it with the offending file and line. (That last one is why the check reads
the AST: a regexp matched the commented-out call and reported PASS.)
This gate keeps its subject: the bundle. Keep it that way.
The gate has been negative-tested: corrupting the patch row id, dropping
additionalProperties from an object output schema, loosening a settings
constraint (widening terminalFontSize's min), adding a bare import of
@deepseek-ai/dsh-tools to index.js, and handing register() a {}
parameter schema each fail it with a precise message.
Why check 4 exists
The first installed run of this bundle failed to load with
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@deepseek-ai/schemastery'
imported from .../dsh/index.js
The Harness installs a local-path bundle as a link: dependency, so Node
resolves dsh/index.js's bare specifiers by walking up from that file and can
never reach the packages inside app.asar. The gate did not catch it because
checks 5 and 6 import the real packages by sandbox absolute path โ a different
resolution route from the one the Harness uses. Check 4 closes that gap, and
@deepseek-ai/dsh-tools is now imported nowhere in the shipped code:
ctx.tools.register() accepts the normalized definition directly, and the
authoring helper defineTool is not required at runtime.
Vendored dependencies
The Host half needs a real schemastery schema for Config, and the Harness's
copy is unreachable from a link: install, so npm run vendor:dsh copies
@deepseek-ai/[email protected] and its only runtime dependency
@deepseek-ai/[email protected] into dsh/node_modules/@deepseek-ai/. The
versions match the installed Harness exactly, the npm tarballs are
byte-identical to the in-app.asar copies, and both libraries tag cross-realm
objects with Symbol.for(...) ("schemastery", "ValidationError",
"cosmokit.volatile.write"), so a vendored schema is recognized as volatile by
the Harness's own schemastery. The script is idempotent: it verifies the
registry's dist.integrity against its own lock, and re-runs skip the network
when node_modules/ already matches.
The pin is a gate, not a comment: the settings check compares the vendored
versions against the sandbox's (the install's own) and fails with
"run npm run vendor:dsh" when they differ, so a Harness update cannot leave a
stale copy silently validating new artifacts.
dsh/package.json deliberately declares no dependencies: a link:
install does not install them, and declaring them would misrepresent how the
bundle resolves.
Why the sandbox exists
The official packages ship only built lib/*.js and are not published to npm
under this preview, so the runtime contract cannot be assumed from the
documentation. Two findings from running the real packages, both now enforced
by the gate:
output.schemamust be raw JSON Schema: an explicit object node must stateadditionalProperties: true | false, and a bare{ type: 'object' }is rejected byassertSupportedJsonSchemaโ the guardregister()runs;- the authoring
parametersDSL (per-parameterrequired) is input todefineTool, not toregister().register()wants the compiled result, sotools.jsstores normalized JSON Schema and check 5 pins it against the realparameterSchemaSpecToJsonSchema.
Settings
The eight sshManager.* contributions from the extension manifest are
registered as editable dsh settings. The mechanism is not
ctx.settings.installSection() โ that method does not exist (the plan
document assumed it; it was disproved against the installed Harness). What
dsh actually does:
- a plugin exports a schemastery
Configschema;dsh-settingsreads it fromentry.fiber.runtime.Configand keeps only the fields marked.volatile(), then auto-generates the form (autoGeneratedefaults totrue); - writes made through that form are re-validated against the same schema by
configEditor.edit(), so the constraints insettings.jsare a real write guard; resolveConfig()turns each volatile field into a stable accessor, which plugin code reads per call viasettings.js#readSettings(the probe tool reports the effective values this way).
Because this plugin relies on the generated form, it does not call
settings.configure({ auto: false }, ...) โ that is only for plugins shipping
their own page.
| Field | Schema | Rejected input |
|---|---|---|
terminalFontSize |
z.natural().min(8).max(200) |
-1, 4.5, 5000 |
terminalFontFamily |
z.string().min(1) |
"" |
scrollback |
z.natural().min(100).max(1000000) |
"abc", 10 |
semanticHighlight |
z.boolean() |
1 |
idleDisconnectMinutes |
z.natural().max(10080) (0 disables) |
-5 |
zebraStripes |
z.boolean() |
"yes" |
Every field also carries .default(...) and .volatile(); the defaults and the
field table live in settings.js, which is the single source of truth. Check 5
enforces both directions โ the illegal values above must be rejected and the
inclusive boundaries (terminalFontSize 8 and 200, scrollback 100,
idleDisconnectMinutes 0) must stay accepted.
Check 5 also holds the prompt budget: JSON.stringify of {name, description, parameters} over every tool must stay โค 5400 B, measured with long keys (a {n,d,p} measurement is ~4% smaller and would hide a regression). The rules those descriptions used to repeat live once in prompt.js, and ping reports whether that section reached the host โ the assembled prompt is written nowhere readable, so otherwise that claim would be uncheckable.
Check 5 also fails when a field is rendered but nothing reads it โ and that rule is how
sessionLogDirectory and keepAliveInterval were found: both sat in the form, looked
configurable, and changed nothing. They are gone rather than deprecated, because a
declared-but-inert field is worse than an absent one. A profile written before the removal
still starts; schemastery keeps the unknown key and readSettings ignores it.
Install (Desktop)
The Desktop shell owns package management for its own profile
(~/.dsh/profiles/desktop); hand-editing that profile is not supported. Install
this directory as a local-path bundle through the app's Plugin Manager, then
restart the Harness so the Host half loads. dsh/node_modules/ is committed, so
a fresh checkout needs no install step; if it is ever missing, restore it with
npm run vendor:dsh before installing.
Do not create preview HTML or mock shells for this plugin: the installed plugin is the preview. After installing, confirm the row with the Harness's own config dump rather than inferring success from logs.
MCP bridge (M1)
The second thing this project needs from dsh is the Agent actually reaching AT
Terminal's remote operations. Until the Host half carries the domain itself, that
goes through the AT Series Hub, which is a stdio MCP server fronting every AT
Series plugin bridge registered under ~/.at-series/bridges/.
@deepseek-ai/dsh-mcp-client is what mounts such a server, and it is not part of
the default composition โ the package ships inside the Harness, and dsh-base and
dsh-web-app mount zero rows of it ("No server is enabled by default"). So the row
has to be added, once:
npm run dsh:mcp:enable # add/refresh the row in ~/.dsh/profiles/desktop
npm run dsh:mcp:check # same checks, print the block, change nothing
npm run dsh:mcp:disable # remove it again
The script edits ~/.dsh/profiles/desktop/cordis.patch.yml โ the file whose own
header calls itself "your patch layer for this dsh profile" โ by appending a block
between marker comments. Three properties are enforced rather than assumed:
- purely additive: the existing rows carry
!!jsexpressions (!!js ctx.webStartup.port ?? 3080). A YAML round-trip would either throw on the custom tag or flatten it to a string, so the edit is textual, and the script refuses to write unless every original line survives in order. A timestamped.at-terminal-backup-*copy is written first. - additive in the loader's sense: the row is wrapped in
- insert:. The patch layer (dsh-app-boot'sapplyEntryPatches) treats a bare- id:row as an override of an entry that must already exist โ anddsh-mcp-clientmounts zero servers by default, so the first version of this block (a bare row) was silently skipped withpatch: entry "mcp-at-series" not foundand the bridge never registered, no matter how often the Harness restarted. The fix is verified against the realapplyEntryPatchesextracted fromapp.asar: the skipped row and the inserted row reproduce exactly. - idempotent: the managed block is stripped before being re-added, so re-running cannot accumulate duplicates (verified by counting the begin markers after write).
- verified: five install checks run first (managed Node, Hub bundle, profile, linked AT Terminal bundle, patch layer) and each is reported by name.
command is the Harness's own managed Node
(~/.dsh/dsh-runtimes/<runtime>/dependencies/node/bin/node), not a node from
PATH. dsh-mcp-client spawns command with a scrubbed ambient environment, so
depending on the user's PATH would be a guess about how the Desktop app was
launched. HOME is passed explicitly for the same reason.
toolCallTimeoutMs is raised to 120 s because the Hub sweeps every registered
bridge on its first cold tools/list: that measured over 20 s here, against 412
registry records of which 390 are abandoned; the same call answers in ~20โ50 ms
afterwards. The 60 s default would have made the very first call fail. (That first
sweep is also why a naive tools/list probe can look like a hang.)
The setting that is easy to get wrong
AT_SERIES_HOST_APP is required, not optional. The Hub resolves its scope with
function resolveHostAppFromEnv(env) { return slugifyHostAppId(env['AT_SERIES_HOST_APP']) ?? 'unknown' }
and then serves exactly that bucket under ~/.at-series/bridges/. There is no
wildcard โ unknown is itself a real bucket. A Hub started without the variable
therefore serves the abandoned unknown records and reports healthyBridges: 0 for
every provider, which reads as "the Hub is broken" while actually being "the setting
is missing". Measured directly:
AT_SERIES_HOST_APP |
what at_list_providers said |
|---|---|
| unset | host=unknown, 3 providers, healthyBridges=0, no at.terminal |
antigravity-ide |
host=antigravity-ide, 6 providers, each healthyBridges=1, [email protected] with 11 tools |
So enable-mcp.mjs discovers the slug โ the bucket with the newest heartbeat,
via dsh/scripts/lib/at-series-registry.mjs โ rather than assuming it, refuses to
write when nothing has heartbeated recently, and takes --host-app <slug> to
override. Re-run it after moving AT Terminal to a different IDE.
What the Agent sees
The Hub speaks protocol v2 (progressive discovery), so a cold tools/list
returns five meta-tools โ at_list_providers, at_search_tools, at_get_tool,
at_select_tools, at_clear_tool_selection โ and the Agent promotes real tools with
at_select_tools. Verified against the live Hub with the same command and environment
this row uses:
OK host scope: antigravity-ide (detected) โ 10 record(s), newest heartbeat 28s ago
[email protected](11), [email protected](13), [email protected](14), โฆ
OK initialize -> AT Series 0.3.4 [162ms]
OK tools/list -> 5 tool(s) [48ms]
This corrects the plan's M1 exit criterion, which says "11/11 ๅทฅๅ
ทๅบ็ฐๅจ dsh ๅทฅๅ
ท
ๅ่กจ". With progressive discovery the Agent's tool list holds the five meta-tools; the
eleven arrive after at_select_tools. That is the Hub's designed flow (its own
super-ops skill teaches it), and forcing it flat with AT_SERIES_TOOL_DISCOVERY=never
would dump ~82 tools from all six registered plugins into every prompt. The criterion
should read "the eleven are reachable and callable", not "the eleven are listed".
Run npm run dsh:mcp:probe (or node dsh/scripts/probe-hub.mjs) to ask all of this
without involving dsh โ it is the first thing to run when a dsh session reports no MCP
tools.
Two consequences worth knowing:
- the work is done by the VS Code-side extension, so an IDE window with AT Terminal active must be connected to the Hub โ dsh is a client here, not the executor. That is exactly what M1 is for (a deliverable intermediate), and it is also why the Host-half tool port (M5) remains the real destination;
failOnStartupErrorstays at itsfalsedefault: a missing or empty Hub must degrade to "no MCP tools plus a log line", never reject activation of the profile.
Client half (M3/M4 pipeline)
dsh/client.js is the browser half, declared with "dsh": { "client": { "platform": "web", "immediately": true } }
and shipped through exports["./client"]. It registers one right-sidebar tab type โ
kind at-terminal, its guide entry, the pane body and the chip title โ and injects the
panel's stylesheet once. Nothing else.
It is a built artifact, not hand-written: node dsh/esbuild.dsh.mjs bundles
src/platform/dsh/client/** โ plus xterm and its addons, the reused webview/terminal/*
building blocks, and the three stylesheets inlined as text โ into the single ModuleLoader
factory, with react as the only external (the browser baseline provides it). The
committed artifact is byte-checked by the gate's eighth check, same as host.bundle.js,
and the seventh check executes the factory in a sandbox to pin the registration shape.
The build pipeline exists for reuse: the panel is supposed to look and behave like the VS Code variant, and the honest way to get there is to ship that variant's own frontend code instead of a second, drifting imitation.
| Reused from the VS Code variant | How |
|---|---|
webview/terminal/* โ options, WebGL renderer with DOM fallback, search, clipboard and Ctrl+C/V semantics, focus recovery, zebra stripes, semantic highlight, theme, byte decoder + output writer with acknowledge |
imported directly by src/platform/dsh/client/xtermEngine.ts; the engine is the ~200-line glue replacing acquireVsCodeApi().postMessage with the channel |
webview/terminal/index.css, webview/server-form/index.css, xterm.css |
imported as text and scoped at runtime under .att-root (scopeCss() in styles.ts), so no html/body rule or bare class name leaks into the Harness page |
the --vscode-* theme variables those sheets read |
a token bridge on .att-root maps each one onto its --dsw-alias-* Harness counterpart with a literal fallback, so the reused sheets follow the host theme (light/dark) unchanged โ and the xterm theme reader reads computed styles from that same root |
| the server-form markup | the form page renders ServerFormPanel's structure (.server-form-shell โ .form-section-grid โ auth cards โ jump host โ summary โ .form-footer), styled by the original sheet |
Deliberately not reused: src/webview/html.ts (CSP/Uri injection โ there is no
webview document here) and the form's "Test Connection" / "Browseโฆ" buttons (no Host rpc
for either yet; a button that cannot work is worse than no button).
Page layout
Four surfaces, routed inside the pane body โ the assets page is a list and nothing else, with management where the original plugin puts it:
- ่ตไบง (default) โ the grouped server list. Rows carry label,
user@host:port, the auth badge and the stored-password badge; no inline buttons. Toolbar: ๆฐๅข / ๅฏผๅ ฅ / ๅฏผๅบ / ๅทๆฐ. Right-click a row: ่ฟๆฅ / ็ผ่พ / ๅคๅถๅฐๅ / ๅ ้ค (behind a confirm); double-click connects. The status bar carries the asset summary and the real private-key directory. - ่กจๅ้กต โ opened by ๆฐๅข/็ผ่พ in the reused markup above. Inline validation refuses the save before any RPC; the password field stays write-only; the trust select gates the background-connection checkbox exactly like the VS Code form.
- ๆไปถ โ the read-only remote file tree, following the active SSH terminal. See File tree below.
- ็ป็ซฏ โ a real xterm terminal in the
TerminalPanelchrome (status header with the state dot, reconnect button, Ctrl+F find bar). Flow control is byte-based: when produced-but-unrendered bytes cross 256 KiB the panel sendsterminal.pauseand the Host pauses the SSH channel itself (SshSession.pauseOutput); draining below 64 KiB resumes โ the webview's ack semantics, now driven by xterm's write callbacks. Font, scrollback and highlight settings come from the livesshManager.*row config viarpc settings.get(the DSH-31 seam), so a settings edit applies on the next connect. The WebSocket address is resolved per carrier (resolveSocketUrl): an http(s) page (web GUI, LAN browser) uses its own origin; the Desktop shell serves the panel fromdsh-app://appโ where relative fetch is IPC-bridged but a WebSocket is not, andlocation.hostis the unconnectable nameappโ so there the client asks the Host viarpc channel.info, which reports the real loopback port it listened on.
Field-level details, each read out of the installed Harness rather than inferred:
| Fact | Value | Where it comes from |
|---|---|---|
| loader entry | window.__ModuleLoader__.load({ id, factory }), id = package name |
every official lib/client.js; dsh-client-modules keys factories by it |
exports["./client"] |
the authoritative resolution point; accepts a string or a one-level { default } |
clientExportOf() in dsh-client-modules/lib/index.js |
priority |
a band: extension (default) / builtin / fallback โ not a sort key |
SidebarRightTabRegistry's RANKS / coexists() |
| coexistence | one builtin and one extension may hold the same kind; a second registration in the same band throws |
coexists() in dsh-client-ui-sidebar-right/lib/client.js |
guide[] |
order and title required; description and icon optional (icon falls back to a cube glyph) |
EntryBox reads entry.icon ?? CubeGlyph |
| body seat | ctx.slots.inject("sidebar.right.pane.tab", () => ctx.slots.register({ name, key }, Body)), key = the definition id |
dsh-client-ui-sidebar-files/lib/client.js |
| chip seat | sidebar.right.pane.tab.title, same key |
same |
Testing the browser half
test/dsh/client-models.test.ts unit-tests the pure modules the client is built from:
the form model and its validation, the DshServerInput builder (write-only password,
trust-gated background flag), list grouping, the error-code โ copy mapping, base64
framing, the flow controller's thresholds and edges, the terminal frame reducer (every
protocol branch), the CSS scoper and the zh/en dictionary parity.
test/dsh/client-bundle.test.ts executes the built dsh/client.js in a sandbox and
drives the real Body through a small React shim: the loader registration, apply()'s
five registrations, idempotent style injection (and its removal on dispose), the
list-only rows with toolbar and context menu, a failed Host read rendering as a
failure with its code (the services-unavailable regression, pinned), form validation
and the exact save payload, the import/export dialog RPCs, and the terminal websocket
protocol including the pause frame at the backpressure threshold.
The file tree is covered on both sides of the wire. test/platform/dsh/sftp.test.ts drives
the Host face against a fake SftpSession: which terminal is followed, what a disconnect
leaves behind, that a live terminal beats a stale tree, that a refused host key is reported
with its code and fingerprint and that the record does not outlive its attempt, and that
a read failure is never reported as a connect failure. The tree's own logic is unit-tested in
client-models.test.ts (flattenSftpTree's recursion, the .. rules, the disconnected
collapse), and rendered end-to-end in client-bundle.test.ts โ including that nothing is
read until the page is opened, and that a directory that cannot be read shows a coded failure
under itself while its siblings survive.
What neither can cover, honestly: the xterm engine against a real DOM (its building blocks have their own webview tests; the assembled engine is the user's in-app walkthrough) and real SSH bytes (needs a reachable host).
Asset transfer โ the panel's real job
The panel is an asset import/export surface, so a user can move their servers between VS Code and dsh instead of rebuilding them. The Host half exposes three RPC methods on the channel and the browser half renders them:
| method | purpose |
|---|---|
assets.summary |
servers, stored passwords, private-key files, and the real private-key directory |
assets.export |
build a package ({filename, envelope, omissions}); the browser writes envelope out verbatim |
assets.import |
merge a package ({envelope, packagePassword, strategy} โ {summary}) |
The format is the VS Code variant's own, reused rather than reimplemented: src/assets/**
is unchanged, so a package exported here imports there and vice versa. The same goes for the
storage rules, which is what makes a transfer meaningful:
| Asset | On the VS Code side | Here |
|---|---|---|
| Server records | globalState key sshManager.servers |
same key, in the at_terminal_state domain |
| SSH passwords / key passphrases | SecretStorage, sshManager.password.<id> / sshManager.passphrase.<id> |
ctx.credentials references, same key mapping |
| Imported private keys | <globalStorage>/imported-private-keys/<serverId>-<basename>, mode 0600 |
same directory shape under $DSH_HOME/at-terminal/ |
| Host-key trust | not exported (includesHostTrust is a hard false) |
unchanged โ trust stays machine-local on purpose |
Because the domain layer is TypeScript and this bundle is not, the Host services are
compiled: dsh/esbuild.dsh.mjs bundles src/platform/dsh/entry.ts into the committed
dsh/host.bundle.js, and the gate's eighth check fails if that artifact is not the current
build of its sources. Hand-porting the format was rejected โ two implementations of one
format diverge, and the whole point of the feature is that one file works on both sides.
Two failure codes exist because their remedies differ: wrong-password (retry with the
right password) and unreadable-package (the password was right and the payload is from a
format this build cannot read โ retyping will not help).
End-to-end, through the compiled artifact: export two seeded servers โ import into an empty
instance โ both records and the SSH password arrive intact, and a wrong password answers
wrong-password. Record order follows ConfigManager.saveServer's label sort, exactly as
in VS Code.
File tree (DSH-23)
The ๆไปถ page is a read-only tree of the followed SSH terminal's remote filesystem, and read-only is the whole of it: no rename, no delete, no upload, no drag and drop. Those exist in the VS Code variant and will arrive when the Host half grows the RPCs for them โ a control that cannot work is worse than no control, which is the same reason the server form ships without its "Test Connection" button.
Three RPC methods carry it, and each maps onto exactly one SftpManager call:
| method | purpose |
|---|---|
sftp.list |
the tree's state plus one directory's entries (path omitted = the root) |
sftp.goUp |
move the tree's root up one level |
sftp.refresh |
drop cached listings so the next read reaches the server, not the 20s TTL cache |
src/platform/dsh/sftp.ts composes SftpManager โ the same manager, listing cache and
none | active | disconnected state machine the VS Code tree runs โ rather than deriving a
second one. Two facts it exists to absorb are worth stating, because both are invisible from
outside:
- A rejected
verify()loses its reason.createHostVerifier(src/ssh/SshConnectionConfig.ts:187-192) does.then(verify, () => verify(false)), so a verifier that throws arrives at ssh2 as a bareHost denied (verification failed). The terminal path reports a refusal by pushing an event frame; a unary RPC has no frame to push, so the refusal is recorded and consulted when the operation fails. It is cleared at the start of every operation that can open a connection, so what is read back is always this attempt's โ a stale refusal would send the user back to a trust prompt that is already satisfied. - A read failure is not a connect failure.
classifySshFailurespeaks about connects only: to it,ENOENTmeans "the private key file is missing". SFTP operations fail with a protocol status instead, which ssh2 gives a numericcode(SFTP.jstestser.code === STATUS_CODE.EOF) and renders asNo such file or directory/Permission denied. Classifying a read through the connect path reported an unreadable directory as a missing private key โ the classifier runs only when the failure is connect-shaped (a stringcodeor a stringlevel), and reads get their own codes (sftp-no-such-file,sftp-permission-denied,sftp-read-failed).
Which terminal the tree follows, and what a disconnect leaves behind โ the two rules DSH-23's DoD is really about:
- The tree follows the newest live terminal, announced to this face by
createDshTerminalApi's observer. The VS Code variant follows the focused terminal and re-syncs from a registry fed by editor events; dsh has one panel and no such events, so the Host half, which owns the terminals, tells this face directly. A live terminal always wins over a dead one: leaving a stale tree up while a live terminal exists is precisely the lie the DoD forbids. - When no terminal is live the tree keeps showing the last listing, marked as
disconnected, and refuses to read below the root (
sftp-disconnected) instead of returning an empty list โ "this directory is empty" and "nobody can look right now" must not render the same. A tree that never listed anything reportsnoneand says "no active SSH terminal", which is the honest answer rather than a claim about a directory.
The client reads the Host only once the page is visited. sftp.list is the one Host read
that can open an SSH connection, and opening one because the panel mounted โ while the user
is looking at the server list โ would be a side effect nobody asked for. All the recursion
lives in flattenSftpTree (src/platform/dsh/client/models.ts), which is pure and
unit-tested without a DOM or a socket; the view maps its rows one-to-one.
Not covered by automation, honestly: a real remote filesystem. The tree is exercised against
a fake SftpSession (Host half) and a scripted RPC (browser half), so what is proven is the
state machine, the wiring and the rendering โ not what a live server returns.
Is the client half actually running? โ npm run dsh:channel:probe
The browser half is the one part of this bundle that cannot be observed from outside.
The Desktop shell delivers client bundles through its own IPC carrier rather than over
/plugins on the web server โ verified: every /plugins/<pkg>/... request returns
404 here, official packages included โ and the app is not started with a
--remote-debugging-port, so there is no CDP endpoint to read the page's module graph
from either.
So "the sidebar tab did not appear" and "the client half never loaded" used to look
identical. They no longer do: the client half announces itself on activation with
POST /dsh-at-terminal/rpc { method: 'client.hello' }, the Host half records the
arrival (rpc client.status, and clientHellos/lastClientHello in rpc state.get),
and the probe script asks from outside:
npm run dsh:channel:probe
It scans every listening port for a channel, then reports one of three answers โ routes registered / browser half arrived / browser half never seen โ plus the fourth case that matters for diagnosis, "this Host half predates the announcement, restart it". The announcement is fire-and-forget and guarded on both ends: a diagnostic must never be able to break activation.
Run npm run dsh:channel:probe before believing any claim about the client half,
including one from this document.
Roadmap
Milestones and task nodes live in the AT repository:
docs/plans/2026-09-28-at-terminal-dsh-่ฟ็งปไปปๅก่ฎกๅ.md, and the feasibility
analysis in docs/research/2026-09-28-at-terminal-่ฟ็งปไธบDSHๆไปถ-ๅฏ่กๆงๅๆ.md.
Corrections that plan made on contact with the installed Harness (it assumed
0.1.1-rc.2 and a dsh web CLI):
- the installed product is the Desktop app (
0.2.1-rc.5as of this revision; it was an earlierrcwhen the plan was written), and installs go through the Desktop Plugin Manager, notdsh plugin --profile web add; - the settings API is a schemastery
Configexport plus.volatile()fields, notctx.settings.installSection()โ see Settings; - dsh already ships a local-shell terminal (
dsh-terminal+dsh-api-terminal-controller+dsh-client-ui-sidebar-terminal) and a file sidebar. AT Terminal's differentiated value is the remote SSH terminal and its asset/host-trust model, so the UI work should extend the official right-sidebar tab registry (ctx.sidebarRightTabs) instead of shipping a parallel terminal UI.ctx.subprocessaccepts only one provider per composition, so replacing the subprocess provider to route the shipped terminal over SSH is not an option.