Package and install code-server (the web version of VSCode) as a plugin within dsh to quickly achieve professional file editing.
Install
# from npm (prebuilt)
dsh plugin --profile web add dsh-code-server-app
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:jinsiyu/dsh-code-server-app
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
Source repository: see
repository/homepageinpackage.json.
⚠️ Extension Marketplace Note (important)
- code-server's extension store is Open VSX, not the Microsoft Visual Studio Marketplace;
- Microsoft's Marketplace terms prohibit third-party products (including code-server) from using its API, so code-server cannot query Microsoft's extension list;
- As a result, Microsoft commercial/proprietary extensions (e.g. GitHub Copilot, the Remote series like Remote-SSH, Azure tools, IntelliCode) are not available in the store — this is Microsoft's distribution policy, not a defect;
- Microsoft open-source extensions (Python, TypeScript debugger, ESLint, …) are mirrored on Open VSX and install normally by search;
- If you need a proprietary Microsoft extension: download the
.vsixfrom the Marketplace page and install it manually withcode-server --install-extension <file>(or drop it into--extensions-dir).
A static profile plugin (npm package with host + client bundle) that ships the VS Code server tree from a code-server release as a platform-independent dependency package (pack-time artifact vendor/vscode → @jinsiyu/dshcs-vscode-server, no install scripts, no postinstall). The code-server Node service layer is replaced by the plugin's own lib/launcher.mjs: it drives <tree>/lib/vscode/out/server-main.js (loadCodeWithNls() / createServer() / handleRequest() / handleUpgrade()) directly and re-adds the few HTTP endpoints code-server used to provide (/healthz, /manifest.json, /_static/*, /proxy/:port). The 16 native modules (node-pty / @vscode/sqlite3 / spdlog / …) come from @jinsiyu/dshcs-* sub-packages declared directly on the plugin's dependency table under their real names (os/cpu-gated per target), with the original import names restored by runtime junctions. VS Code's inner dependencies and the prebuilt native modules are all installed by the package manager together with the plugin — no global npm install, no bin configuration, no profile config changes, no second install command, no argon2/C++ toolchain.
The "ask DSH" dialog renders the session's new content with DSH's own Markdown renderer and can answer approval requests in place (writing outside the workspace / running commands). The panel is a hand-written React component inside
lib/client.js: it requiresreact-dom/clientand@deepseek-ai/dsh-client-ui-primitivesstraight from the DSH page's module table (the very instance the UI uses), so typography, highlighting and math match the UI and a renderer version mismatch is impossible. There is no build step anywhere on that chain. See "Working with DSH: the editor bridge".
UI carrier and required DSH version (0.2.3: right-sidebar DSH only; converged 2026-10-01 to one generation, ≥ 0.2.0-rc.2)
Every verdict is a capability probe, never a version comparison. Exactly one generation is supported:
DSH ≥ 0.2.0-rc.2 (web and desktop are the same generation — both UIs have moved to 0.2.0-rc.2). The
compatibility branches written for the rc line 0.1.5-rc.x and the alpha line
0.1.6-alpha.2…0.1.7-rc.x have been deleted: the old seat settings.plugin.item, the old data channel
settingsScope, the session-list snapshot's current fallback and the recentWorkspaceId fallback are all
gone (see "Settings" and "Legacy DSH"):
| DSH version | Carrier | Entry points |
|---|---|---|
≥ 0.2.0-rc.2 (web and desktop are the same generation) — detected by the presence of sidebarRight / sidebarRightTabs, never by version comparison |
Right-sidebar tab (kind code-server, chip Code Server), which also claims file addresses (see below) |
① DSH's own produced-file chips / presented-file card previews / inline file names in prose (since 0.2.5, via the official openFile → file address → this tab); ② the Code Server box on the sidebar's guide ("开始") page; ③ the settings block (location per "Settings") → "Open in right sidebar" |
| older (no sidebar service) | Unsupported: nothing but one notice in the settings block | none (the settings block shows an upgrade notice) |
- Detection: first a synchronous
ctx.get('sidebarRightTabs') / ctx.get('sidebarRight')probe; because the services may come up after this plugin,ctx.inject(['sidebarRightTabs','sidebarRight'], …)is awaited and a 2.5 s timeout marks the DSH as legacy (no version comparison, and the plugin's own activation is never blocked). Since 0.2.4 that verdict is reversible and registration no longer relies onctxproperty access (which on desktop silently skipped registration — the symptom was "settings card looks normal but the sidebar has no entry"):- services are looked up as
ctx.<name>first andctx.get(name)second, so either context shape registers; - when the sync probe already sees the services but
injectnever calls back, registration falls back to the sync services after 1.5 s; - at 2.5 s only the settings notice appears; only after 10 s does the client tell the host to recycle/stop prestarting (so a slow host is not punished);
- services arriving late automatically revoke the legacy verdict, register the sidebar, and report
{sidebar:true}so the host re-enables; - a failed registration is no longer silent: it logs an error and the card's entry row says "right-sidebar services were found but the tab could not be registered".
- services are looked up as
- 0.2.3 dropped legacy-DSH compatibility: the floating ball and the internal floating window are deleted. When the DSH is detected as legacy the plugin
- registers only the settings notice (location per "Settings") — no ball, no floating window, no file-address claim, no IDE preload;
- reports
/api/code-server/ui-mode { sidebar:false }to the host (after the 10 s grace above); the host then recycles an instance it auto-prestarted and stops prestarting (a user-started/adopted instance is never touched), and{sidebar:true}reverses that if the services show up later; - upgrading DSH needs no reinstall — refresh the page and the notice turns back into the full settings form.
- The sidebar tab hosts the code-server page (iframe) and follows the current session workspace; the panel can be collapsed/split/floated/fullscreened by DSH's right sidebar.
- Fullscreen on open (0.2.9, on by default): opening the Code Server tab (including clicking a produced-file chip / delivered-file preview / inline file name) switches the right sidebar from "side by side with the conversation" to fullscreen (fills the window) — an IDE is cramped in a narrow column.
It only affects that moment of opening: clicking the sidebar's own "Exit fullscreen" is never fought back; switching away and back, or opening another file tab, goes fullscreen again.
Turn it off in the settings card (
fullscreenOnOpen=false) to stay side by side.- How it is done: DSH does not expose the mode to plugins —
ctx.sidebarRightonly hasisExpanded/toggleExpanded(expand/collapse), while push ⟷ fullscreen is recorded inui-sidebar-right's own store (actions.setMode, handed only to its own seat components).ctx.layout.openRightbar(track, fullscreen)is not a control either: it is the channel the seat reports its presentation through (upstream comment: the occupant reports it; nothing else writes it). So the plugin performs the user's own gesture: it locates its own panel withclosest('[data-sidebar-right-panel]')and clicks the panel chrome's[data-sidebar-right-mode="fullscreen"]button (the exact same path as a manual click, including the narrow-viewport handling). When the button is missing it keeps the current mode and logs oneconsole.warn— panel rendering is never affected.
- How it is done: DSH does not expose the mode to plugins —
- Resident IDE (0.2.2, on by default): switching to another tab or collapsing the sidebar and coming back no longer reloads code-server — unsaved editor buffers, terminals and debug sessions all stay put (see "Why switching tabs no longer reloads" below).
- The settings card has exactly four settings: "Claim types", "Fullscreen on open", "Resident in background" and "FIM completion (experimental, off by default)" — no other rows (0.2.7 removed the "Entry", "dependency install" and "environment check" rows).
Open the IDE from the Code Server box on the sidebar's guide page, or by clicking DSH's own produced-file chips / delivered-file previews / inline file names;
diagnostics stay out of the UI — the
[code-server]lines in the DSH host log are the place to look (/api/code-server/statusstill returnsenvfor scripts). The oldwindowedOpen(open in a window) andreserveComposerwere removed in 0.2.6: leftover keys in an old settings document neither fail nor apply (they are no longer part of the schema). To use the IDE in a browser tab, copy the full address from the settings card / empty-state hint (it contains the path token:http://127.0.0.1:<port>/<token>/; underserve: dshit is DSH's/code-server/) — dropping the token segment yields a 404.
Opening files (official entry points since 0.2.5)
DSH names files with resource addresses; openFile only hands the address to the right sidebar, which decides who draws it:
DSH's produced-file chip / presented-file card preview / inline prose mention
→ openFile(path, { line? }) (provided by ui-chat)
→ dsh-resource://file/session/<sessionId>/<path> (or …/file/absolute/<path>)
→ ctx.sidebarRight.openResource(address)
→ claimed by the tab type whose patterns match (band extension(3) > builtin(2) > fallback(1),
then the longest matching pattern, then registration order)
This plugin registers:
| Field | Value | Effect |
|---|---|---|
patterns |
['dsh-resource://file/**'] |
claims file addresses (a pattern containing : is matched against the whole address) |
priority |
'extension' |
beats the built-in plain-text preview, which sits in fallback on purpose — DSH's own comment calls that band "the position VS Code's text editor holds among its editors", i.e. one any more specific type should beat |
canOpen |
see below | vetoes by the "claim types" setting; unclaimed addresses fall back to DSH's built-in preview |
title |
last address segment (= file name) | the tab chip shows the file name; a page tab (sidebar://code-server) still reads Code Server |
- Claim types (a text box in the settings card,
claimExtensions, since 0.2.11): scope is no longer a thing —dsh-resource://file/session/…and…/file/absolute/…are treated alike, and only the extension decides. Text-box grammar (semicolon-separated;,/whitespace/newlines also work;py,.pyand*.pyare equivalent; case-insensitive):*— claim every other type too (catch-all);py— claim.py;!md— do not claim.md(exclusion wins over both an explicit claim and*);- default (since 0.3.51) excludes three groups:
① the four categories DSH's own preview renders well —
md markdown html htm png jpg jpeg gif webp bmp ico svg pdf; ② executables and binary artifacts —exe com msi msix msixbundle appx appxbundle dll sys scr cpl ocx drv efi mui,obj o a lib pdb class jar pyc pyo wasm node,so dylib ko elf bin out,apk ipa deb rpm dmg iso img cab; ③ Office and layout documents —doc docx docm dot dotx dotm docb rtf odt,xls xlsx xlsm xlsb xlt xltx xltm xla xlam ods,ppt pptx pptm pot potx potm pps ppsx ppam odp,vsd vsdx vssx vstx vsdm vssm vstm one onetoc2 mpt mpp pub msg xps oxps odg. Everything else (code, json/yaml, txt, logs, extension-less files such asMakefile, unknown extensions) goes to the IDE; an empty box claims no files at all (page tabs only).- The test is "does an editor make sense here", not "can it be executed": text-shaped scripts
(
batcmdps1shpyjs…) andcsv/tsvstill go to the IDE. - To re-enable a group: put the short whitelist
*;!md;!markdown;!html;!htm;!png;!jpg;!jpeg;!gif;!webp;!bmp;!ico;!svg;!pdfback into the box (Office and executables return to the IDE, the preview-friendly ones stay with DSH). - The three lists are exported constants (
PREVIEW_FRIENDLY_EXTENSIONS/EXECUTABLE_EXTENSIONS/OFFICE_EXTENSIONS); the default is their union, and the card's "actual rule" summary names "executables, Office documents".
- The test is "does an editor make sense here", not "can it be executed": text-shaped scripts
(
- Three practical shapes: a plain whitelist (
py;ts, no*→ nothing else is claimed), catch-all (*), and catch-all plus exclusions (the default). - Grammar, default and parsing all live in
lib/claim-types.js(the host'sConfigdefault and the client'scanOpenshare that single file, shipped in the package, so the two cannot drift apart); unit tests:scripts/test-claim-types.mjs.
- How the tab body locates the file: it parses
useTabInfo().tab.navigation.address(lib/client.js, the "address grammar" section — same grammar as DSH'sparseFileAddress), expands a workspace-relative path with that session's cwd, and posts the absolute path (plus optionalline) to the host's/api/code-server/open-file; the bundled extension (dshcs-open-file) then callsshowTextDocument(positioned at the line when given). - A single tab (since 0.3.57): DSH's own semantics are "one address = one tab" (
contentIdis the address — same address is idempotent, a different one always opens a new tab), andreplaceTabcan only be passed by the opener (the product's ownopenFile/openResource) — so the plugin instead closes the older code-server tab in the same pane when a new tab's body mounts. Clicking a second file now changes the content of the one tab (its chip title follows) instead of stacking tabs. Two deliberate boundaries: (1) only same-pane tabs are closed — a split layout is the user's own doing, and the product's convention is "one page per kind in each pane" (SidebarRightTabDefinition.multiple); (2) only a tab that becomes visible for the first time consolidates — tab restoration/activation order is not ours to control, and letting every visible tab close its siblings would ping-pong (arefpins "once per mount"). These tabs always shared one resident workbench (the IDE is a single instance), so closing one never reloads it: the resident iframe is owned by the "resident IDE surface" section oflib/client.js, and a tab is merely its docking host (Element.moveBefore). Regression:scripts/test-client-bundle-tabs.mjsrenders two tabs against the entry file and asserts the old one is closed, the new one stays, other panes/hidden tabs are untouched, and an old DSH withoutactionsdoes not throw (negative control: dropping the consolidation call makes it FAIL). - Why the bundled extension stays: VS Code Web has no official "open this file from outside" API (the only
entry is
?folder=, which picks the workspace), so aiming the workbench at a file has to be done by an extension inside the tree. The host writes a signal file, the extension polls it and callsshowTextDocument, keeping the signal for retry when no window is connected yet.
Why switching tabs no longer reloads (resident IDE)
The old trap: DSH's right sidebar (ui-dockkit) renders only the active tab's body
(TabPanel.tsx:412 → renderTab(active)) — switching to another tab unmounts that body in React, which moves the
iframe out of the document and destroys its browsing context; switching back is a full VS Code reload (unsaved buffers
lost). Floating the tab into its own panel only worked around it.
What it does now (the resident-surface section of lib/client.js, 0.2.2): the plugin takes the iframe away from React and turns
it into a singleton resident surface:
| Situation | Action | Result |
|---|---|---|
| tab becomes active | host.moveBefore(frame, null) into the visible dock slot |
state-preserving atomic move, no reload |
| tab deactivates / sidebar collapses | move back into a document-level park container (offscreen, keeps last docked size, inert + aria-hidden) |
never destroyed, keeps running in the background |
| workspace / port changes | assign src explicitly |
the only normal "reload" entry point |
- Why
moveBefore: measured in a real browser (Edge/Chromium 151), a plainappendChildmove resets the iframe's internal timers (i.e. reloads it), whileElement.moveBefore()(Chromium ≥133) preserves state (a probe counter keeps counting 1→2). - Degradation is never silent: when
moveBeforeis missing, or the host was already detached by React and it throwsHierarchyRequestError: invalid hierarchy(passive effect cleanup runs after DOM removal), the code falls back toappendChild— one reload, but the frame is never lost — and reportsdegraded/lastMoveErrorso the UI can say "residency unavailable". - Repaint fallback (measured): in the real GUI the surface was seen once with correct size, hit testing and
visibilitythat simply stopped repainting (a fully white panel, byte-identical screenshots proving no new frame).translateZ(0)andopacitynudges did nothing;display:none → forced reflow → restoreinside a single JS task restored it without reloading the iframe document, without losing internal state and without a visible flash. The trigger could not be reproduced: in a probe page an offscreenmoveBeforepark of 337 s (past Chrome's ~5 min cross-origin throttle window) followed by a dock with the fallback disabled still painted normally. It is therefore kept as a fallback: every park→dock transition runs onenudgeRepaint()(counted assurfaceSnapshot().nudgeCount;setNudgeEnabled(false)A/Bs it live). - Warm-up: with
keepResident(defaulttrue) the host builds the surface right after plugin start and leaves it parked, so the first tab open needs no cold start; preloading never yanks a surface that is currently docked. - Debug handle:
window.__dshcsSurface(snapshot(),setParkStrategy('offscreen'|'behind'),dock(),park(),nudge(),setNudgeEnabled(false),destroy()).
Measured (DSH web GUI, real mouse clicks between sidebar tabs): switching away → docked:false, same iframe node,
in-frame probe still alive, degraded:false; switching back → docked:true, unchanged src, IDE pixels and editing
state preserved (no full reload). Full evidence and probe scripts: docs/analysis-code-server-as-dsh-plugin.md.
Serving mode (serve)
| Mode | What it does | Requires |
|---|---|---|
loopback (default) |
the plugin listens on its own loopback port (port: 0 by default = a random port assigned per start), the sidebar iframe connects cross-origin, and the URL carries a random path token (http://127.0.0.1:<port>/<token>/, see "Security model of the loopback port" below); the process can be adopted after a DSH host restart |
nothing |
dsh |
the IDE is mounted on DSH's own HTTP port at /code-server/* (HTTP prefix route) plus /code-server/<quality>-<commit> (exact WebSocket route), forwarded to the launcher's named pipe; no extra port; every request (including the WS handshake) first passes ctx.connection.requestRejection() — the same Host/Origin fence and browser-cookie authentication as /api |
DSH providing webServer (web profile); desktop falls back to loopback automatically |
Security model of the loopback port (since 0.2.14)
loopback is the only transport desktop has (no webServer, no same-origin mount), so it is hardened on its own:
- Random port:
portdefaults to0→ the OS assigns a free port and the launcher writes the actual one to$DSH_HOME/code-server/endpoint.json, which the host reads back. The port therefore changes on every start and the old "8090 is busy" class of conflicts is gone. Pinportexplicitly if you need a fixed address. - Path token: a fresh 32-character token (
[0-9A-Za-z_-], 24 random bytes) is generated on every new start, stored in$DSH_HOME/code-server/path-token(inside the user profile, readable only by the owner under the default ACL), and becomes the URL path prefix. Requests without that prefix get a plain 404 (nothing reveals that an IDE lives there); a prefix without the trailing slash is answered with a 302. - Why not VS Code's own
connection-token: it works through?tkn=→ 302 +Set-Cookie: vscode-tkn; SameSite=Lax. The desktop iframe is cross-origin (dsh-app://→127.0.0.1), and a Lax cookie is not sent from a cross-site subframe — the IDE would simply fail to load. A path prefix needs no cookie at all: the workbench derives every asset and WebSocket URL fromlocation.pathname(the same mechanism already proven by mounting under/code-server/inserve: dsh), so the prefix rides along on every subrequest and on the WS handshake. (Verified with a real Edge + CDP run: with a random port and a token, the workbench renders inside a cross-origin iframe and establishes its WebSocket.) - Host allowlist: in loopback mode only
127.0.0.1 | localhost | [::1] : <actual port>is accepted. This is what stops DNS rebinding, whose requests can arrive without anOriginheader and therefore slip past theOrigin == Hostcheck. Referrer-Policy: no-referrer: the token lives in the path, so it must not leak throughRefererwhen external resources load.- The token never reaches argv or the logs: command lines are readable by any local process, so it travels through a file; the log only says "enabled".
Boundary, stated plainly: this layer stops other local applications, port scanners and browser pages from casually reaching your IDE. A malicious program running as the same user can already read your files and that token file — that is outside this plugin's threat model.
Switch it in
config.serveincordis.patch.ymlor in Settings → Plugins → Code Server (takes effect on the next start).Benefits of
dsh: a single URL/port (remote access to DSH gives you the IDE), no extra loopback listener, authentication on par with DSH.Two known trade-offs of
dsh: the iframe shares DSH's origin, sosandboxis dropped there (same-origin plusallow-same-originis escapable by the frame itself; inloopbackmode the iframe is cross-origin andsandboxstays as real protection — clipboard is still granted viaallow="clipboard-read; clipboard-write"); and forwarded-port WebSockets cannot be routed becauseregisterUpgradematches exact paths while/proxy/:portcarries the port in the path (HTTP forwarding works; useloopbackwhen you need WS forwarding).In
loopbackmode every upgrade passes a code-server-equivalent Origin check (since 0.2.1): when anOriginheader is present its host must equalHost(honouringForwarded: host=/X-Forwarded-Host, like code-server), otherwise the handshake gets403; non-browser requests withoutOriginare allowed. Without that check any local browser page could complete a handshake againstws://127.0.0.1:<port>/stable-<commit>and drive the IDE.
Working with DSH: the editor bridge (since 0.3.0, on by default)
Having the IDE next to DSH and having the agent know what is going on in the editor are two different things. The editor bridge covers the second half: it is a read-only channel that hands the agent what only the editor knows, and lets editor gestures drive the current session.
| Direction | Capability | Mechanism |
|---|---|---|
| editor → agent | unsaved buffers (disk ≠ what the user sees), active file and selection, language-server diagnostics with file:line, source and code |
agent tools editor_context / editor_diagnostics; plus a notice attached before writing a dirty file |
| editor → DSH | select code → context menu "DSH: ask about selection" → an ask dialog opens in the DSH page (its title bar carries file:line); the question enters the current session as user input, and that session's new content is rendered in the dialog by DSH's own Markdown renderer |
extension command dsh-code-server.askAboutSelection (one of the top two editor context-menu items) → bridge POST /event {kind:'ask-open'} → the client half's POST /api/code-server/ask/send |
| DSH → editor (approval) | when the agent wants to write outside the workspace or run a command, the approval request shows up as a card in the dialog (tool, reason, countdown); "allow once" / "reject" takes effect immediately | the approvals field of /api/code-server/ask/state + POST /api/code-server/ask/approve (the plugin's only write route; constraints under "Security model") |
| agent → editor | the agent changed a file → a native diff opens (left = the full pre-write text, right = what is on disk now); if that buffer has unsaved changes you get a warning and no overwrite | the host reads result.value.before (the complete pre-write text) in tools/post-execute into a bounded snapshot cache → the tools/result event carries an opaque key → the extension fetches the text and opens the diff |
- The tools are only registered while the bridge is live (so the model never sees an unusable tool), and the system-prompt section renders only then too.
- Ask dialog: the context-menu command only reports its intent to the host; the dialog itself is popped up by the plugin's client half inside the DSH page — draggable, resizable (bottom-right, ✕ closes it), leaving the editor layout alone. The selection can still be changed while the dialog stays open. When the host cannot prove the dialog is alive (page not open / browser still running an old client) you get a one-line notice telling you to open or refresh the Code Server tab — there is no second ask UI in the editor.
- The two commands remember their intent: "ask about selection" carries a line range + selection text only when something is actually selected, while "ask about file" never carries line numbers or a selection — the cursor line is irrelevant to the question and only misleads the agent. With no selection, the selection command also degrades to the plain file. The host takes that context from its cached editor state; the extension only reports the intent.
- Follow-ups are delivered according to DSH's own setting:
ui-conversation.busyEnter(Settings → Conversation, "Enter while busy") acceptsqueue(the default) orsteer. Pressing Enter in the dialog is the same gesture as pressing Enter in the main composer, so it reads the same value:steer⇒ the host callsagent.steer()and the follow-up is consumed at the running turn's next step boundary (answered within that turn);queue⇒ the host callsagent.followup()and the question becomes its own later turn, leaving the running one alone. If the setting cannot be read (namespace unregistered / minimal composition) or the host has noagent.steer, delivery falls back toqueue— a setting never makes a question undeliverable. The panel's status row says which one was used ("inserted into the current turn…" / "queued for the next turn…"), because whilequeueis in effect the DSH main UI cannot show that message yet: it sits in the host-side pending queue (next-turn) and the main client does not render pending queues (it joins the chat flow only once it becomes its own turn). The message is not lost. - Injected context is collapsed: the location line plus the selection code block the bridge adds to the message are split out into a collapsed Context row (click it to see the code), while the bubble keeps only the user's own words — the same treatment the DSH UI gives injected context.
- The body is exactly what DSH renders: it is handed to DSH's official Markdown renderer
(
MarkdownTextfrom@deepseek-ai/dsh-client-ui-primitives) — the same micromark/mdast pipeline, the same incremental streaming parser, the same shiki highlighting (DSH's own lazily-loaded grammar set), KaTeX math and the same heading/table typography. Only new content is rendered (from the moment the dialog subscribes); history is not replayed and there is no "load earlier". If the official components cannot be resolved the body degrades to plain-text<pre>instead of a blank panel. - Thinking shows up like in DSH: assistant reasoning becomes a Think row — collapsed by default,
showing its first line (or the latest line while streaming) and expanding on a row click, built from the official
DisclosureRowplus the official think icon and typography language. - Approvals are handled right in the dialog: while it is open, that
session's approval requests ask the dialog first (5-minute window). Clicking "allow once" / "reject" settles it
immediately; closing the dialog or letting the window expire hands the request back unchanged to the official
path (the DSH UI shows the same card).
Nothing is ever auto-approved —
allowed-oncecan only come from a click, and there is no "always allow". - The question enters the DSH session as a plain user message (
source: { kind: 'user' }): provenance stays in the first line of the text (From the editor: <file>[:<line>]), and the panel folds it into the Context row. - The bridge is completely read-only: it never writes files, applies edits, or runs commands — all four routes
(
/health,/sync,/old,/event) are reads. The only route that can change state is the DSH-same-originPOST /api/code-server/ask/approve, which can only answer an approval request that already exists (see invariant 2 below). The agent's writes still go through its ownfstools; the bridge only knows about them and carries your answer back. - Status bar shows
$(plug) DSHwhile connected (click it for the log in the "DSH Editor Bridge" output channel). - The extension ships as a built-in:
dshcs-editor-bridgeis installed into<tree>/lib/vscode/extensions/next todshcs-open-file— an extension left in the user extensions folder is marked.obsolete(log lineMarked extension as removed) by the VS Code server and skipped forever. To turn the bridge off use the plugin settingeditorBridge=false(no mount, no tools).
The channels (since 0.3.13 over local IPC: a Windows named pipe / unix socket)
extension → host POST /code-server-bridge/sync one round trip: push editor state + take events and the capability bit
extension → host GET /code-server-bridge/health unauthenticated liveness probe
extension → host GET /code-server-bridge/old fetch one "pre-write text" snapshot (events carry an opaque key)
extension → host POST /code-server-bridge/event report intent: open the dialog / open·close a file etc. (host log tail)
host → extension <extensionsDir>/.dshcs-bridge/bridge.json endpoint + token, re-read every 5s
(the same content is also written **next to the built-in extension** in
`<tree>/lib/vscode/extensions/.dshcs-bridge/` — the env var is only injected when the host
spawns the IDE, and an **adopted** IDE is a process from an earlier start that never saw it,
so the extension must be able to find the config from its own location alone)
Requests use http.request({ socketPath }) (fetch has no socket support) and no port is ever opened.
All four routes are read-only; questions and approval answers do not go through the bridge but through the
DSH-same-origin /api/code-server/ask/* (called by the plugin's client half inside the DSH page, under DSH's own
cookie/Origin checks).
The four state fields the dialog actually consumes (GET /api/code-server/ask/state?rev=N; an unchanged revision
returns a single number):
| Field | Content | How the panel uses it |
|---|---|---|
entries |
new content entries (user / assistant / tool / approval) of the session the dialog watches; bounded: ≤120 entries per session, ≤8000 chars per body, ≤4 watched sessions | assistant bodies go to the official renderer; tools and approvals become compact summary rows |
approvals |
pending approval requests [{id, toolName, reason, at}] (≤4) |
renders the card with a countdown; a click posts /ask/approve |
approvalHoldMs |
the approval window (300000 ms = 5 minutes by default) | countdown basis |
contextText / mode |
the title line (from the host's cached editor state) plus the ask intent | title text; mode decides whether line numbers / the selection travel with the question |
Why not HTTP (settled in 0.3.13, all three measured)
- Desktop has no HTTP surface at all: the renderer calls
host.fetch()through Electron IPC (createSharedFetchHandler('/api')inapps/desktop-host/src/index.ts:308) — an in-process call, unreachable from another process; the only HTTP a plugin can mount is the web profile'swebServer./apicannot carry it either: Connection puts a Host/Origin/cookie fence on/api(requestRejectioninpackages/client/connection/src/index.ts→ 401 without a cookie), while the bridge's client is a Node process inside the extension host — it can never hold a browser cookie. Measured on 0.3.7: polling/api/code-server/bridge/syncreturned either 405 (it reached the launcher/VS Code) or 401 (the fence) — the bridge had never actually synced.- The two ends are processes on the same machine anyway (extension host ← the IDE the plugin spawned ← the plugin). Local IPC is strictly smaller than a port: no network surface, no Host/Origin confused-deputy path, and web and desktop share one path. Token auth stays (see below); the Windows pipe name carries a random suffix and the POSIX socket file is
chmod 0600.History: 0.3.9–0.3.12 mounted it on DSH's
webServerprefix — which left desktop permanently dormant.
Why state is pushed, not pulled: the extension host is a child process of the VS Code server and listens on no port — the host cannot call into it. Editor state therefore rides the extension's own polling request, and the host caches it for the tools (at most one 600 ms cycle behind; older than 10 s and the tool says so instead of passing stale data off as fresh).
Why no SSE/WebSocket: the extension host has no HTTP server of its own; the bridge's shape is one request/response round trip every 600 ms. Polling also buys two useful properties: it is idempotent (a dropped event only costs one notification — the data always lives in the editor) and the cached state is inherently fresh.
Event delivery contract (fixed in 0.3.56): agent-edit notifications live in a ring buffer and are fetched with
since=<cursor>; the sequence is monotonic for the lifetime of one bridge endpoint (host process), the client's
cursor only moves forward, and a new endpoint (the pipe name carries the host pid) re-aligns with since=0.
From 0.3.9 through 0.3.55 the host called reset() after every /sync, and that call rewound the sequence
counter — so once the extension had seen seq=1, every later event was numbered 1 again and seq > since never
held: at most one event per IDE session was ever delivered (exactly the "a diff rarely shows up, and when it does
its old side is empty" the user reported). Now reset() only clears the buffer, /sync also returns lastSeq (the
high-water mark), and the extension uses it to notice a cursor that ran ahead and re-align to 0 on its own.
Regression: the "push → take → clear, twice" case in scripts/test-bridge-routes.mjs. This remains a
notification channel, not a reliable queue: 64 entries, oldest dropped, a lost event costs one notification.
Security model (five invariants; read before touching lib/bridge.mjs)
The token lives in <extensionsDir>/.dshcs-bridge/bridge.json, readable by any process of the same local
user, so:
/code-server-bridge/*is read-only, with two bounded exceptions. No route writes files, edits documents, runs commands, or spawns processes. A leaked token is therefore bounded to "sees information that is in the editor" and can never become arbitrary file writes or command execution. A whitelist assertion inscripts/test-bridge-routes.mjsguards this./complete(0.3.61, experimental FIM completion, off by default) is the one route that makes a model call: it takes two strings (the text before/after the cursor) and returns one string. It still writes nothing and runs nothing, is disabled unless the setting is on (403 otherwise), and is bounded by length/rate/concurrency/timeout caps — so the worst case for a leaked token grows only to "spends a little completion budget and reads back one completion"./old(added in 0.3.55) lives under the same invariant: it only reads the bounded cache of "pre-write copies of the last few agent writes" (≤8 entries, ≤1 MB each, ≤4 MB total, 5-minute TTL) by opaque key, 404s when it is gone, takes no path argument (so it cannot read arbitrary files) and does not consume (repeat polls get the same text).- The four constraints on
/approve(drop one and it becomes an arbitrary-command-execution back door): (a) it can only answer an approval request that already exists — the body is exactly{id, outcome}, with no free text, paths, or command arguments, so it can answer questions but never start an action; (b)idmust belong to a request this process created and that is still pending (single use); (c)outcomeaccepts onlyallowed-once/rejected— there is no "always allow"; (d) when no panel is watching, the panel is closed, or the window (5 minutes by default) expires, the request goes back to the official path — never auto-approved (DSH'sapproval/requestitself fails closed; this bridge can only keep "nobody answered" as "nobody answered").pnpm test:ask-dialogasserts these four plus the host-side whitelist. - Any request carrying
Origingets 403. Browsers always send one (including a sandboxed iframe's literalOrigin: null); the Node extension host never does. Origin is checked before the token — otherwise the bridge would be a "did you guess the token right" oracle for a web page. - Paths are confined to the editor's current workspace folders.
- Everything is bounded: 200 diagnostics, 500-char messages, 256 KB request bodies, a 64-entry event ring,
≤120 thread entries per session (≤8000 chars each, ≤4 watched sessions), ≤4 pending approvals and ≤8 pre-write
snapshots (≤1 MB each, ≤4 MB total, 5-minute TTL — see
/old).
This layer stops "another local app or a browser page that got hold of the file". A malicious program running as the same user could read your files and the token anyway — that is outside this plugin's threat model, exactly as stated for the loopback port.
Turning it off / diagnostics
| How | Effect |
|---|---|
config.editorBridge: false in cordis.patch.yml |
next start writes no bridge.json and registers no tools |
code-server.editorBridge: false in the settings document |
immediate: config removed, tools unregistered, the extension goes dormant |
disable the dshcs-editor-bridge extension inside the IDE |
the bridge simply becomes unavailable |
Diagnostics: GET /api/code-server/status exposes
bridge: { enabled, live, toolsRegistered, supported, url, file } — never the token (that only exists in the file).
FIM completion (experimental, since 0.3.61, off by default)
When you pause while typing, a grey continuation appears after the cursor (Tab accepts, Esc discards). It is off by default; turn it on with that row in the settings card — it takes effect immediately (no host restart, no reinstall).
| Item | Value |
|---|---|
| Endpoint | DeepSeek FIM (Beta): POST https://api.deepseek.com/beta/completions, params prompt (prefix) + suffix (suffix) |
| Model | deepseek-flash (the model the official FIM doc uses — also this deployment's default) |
| Credential | Reuses the DEEPSEEK_API_KEY DSH already has: ctx.get('credentials').resolve(...), the same path the official adapter takes (falling back to the launch environment) |
| Measured latency | 112–416 ms (non-streaming; streaming was slower, hence non-streaming on purpose) |
| When it fires | After a ≥250 ms pause and only past the gates: non-empty selection / non-file document / empty context / document >20k lines — those never send a request |
How it is wired in (option A): FIM speaks the Completions API, while ctx.llm.stream(GenerateOptions)
only knows messages (no prompt/suffix; purpose is a closed union of 'compaction' | 'session-title').
So the plugin registers its own LLM adapter route dshcs-fim: the prefix/suffix travel inside a
messages envelope with a fixed marker (dshcs-fim/1 ), and the adapter decodes it before hitting that
endpoint. The call therefore still goes through DSH's LLM service — cancellation, timeouts, terminal
chunks and stable error codes all follow the service contract (instead of the plugin bypassing it with a
raw fetch). Evidence and measurements: B6/B7 of docs/analysis-continuedev-reuse.md.
Three knobs (0.3.62, all in the settings card, effective immediately):
| Setting | Default | Effect |
|---|---|---|
| Pause in ms | 250 |
How long typing must stop before a request. Range 100–3000 ms (clamped on save); the endpoint round trip measured 112–416 ms, so the pause is the perceived latency |
| Allow multi-line | on | Off ⇒ the host returns the first line only; an empty first line means no completion. Turn it off to be less intrusive |
| Disable by glob | empty | In these files no request is sent at all. Semantics: * does not cross directories, ** does, a pattern without / matches the basename, a pattern with / matches any path suffix (so vendor/** and src/*.ts work at any depth), a trailing / means /**. Example: *.md;vendor/**;**/dist/** |
Both sides implement these semantics independently (the extension is a static file shipped with the package and cannot import host code);
scripts/test-fim.mjspins their equivalence with one shared (pattern, path) corpus.
Safety and bounds (this is the only capability of the plugin that sends content out):
- Still read-only: the extension never writes files and never runs commands — it only proposes text; insertion happens when you press Tab;
- The bridge gains its fifth route
POST /complete(the only route that makes a model call) only while the setting is on; with it off the route answers 403 and no request is made; - Bounded: prefix/suffix ≤6000/2000 chars (120/40 lines, whichever comes first), output ≤2000 chars, 4 s timeout, 120 ms minimum interval, concurrency 1, 60 calls/minute; over-limit requests are rejected (409/429) and the extension simply shows no completion for that beat;
- One more layer in the extension: turning the setting off disposes the provider immediately (no requests at all), and identical context hits a local 2-minute cache;
Usage is visible in exactly two places: these calls are not session requests, so they are not written
to the session log and DSH's own token accounting (per-turn usage, context pressure, telemetry) excludes
them. Usage therefore shows up in ① the IDE status bar's DSH item — a $(zap) 1.2k counter when on, with
input/output/cache-read/last-latency/last-error on hover, and ② the fim field of
GET /api/code-server/status (same snapshot).
Known limits: the model occasionally invents an insertion where nothing is needed (2/2 reproduced at a cursor position that needed none); the filter pipeline strips code fences and control markers, but "should this be completed at all" stays your call — Esc or typing on makes it disappear. A more reliable shape needs a faster completion route, or DSH making completion a first-class request (at which point only the adapter's data call changes; the caller stays as it is).
Legacy DSH (unsupported since 0.2.3)
Behaviour: when sidebarRightTabs / sidebarRight cannot be found, the plugin registers a single settings notice (location per "Settings"):
Code Server — this DSH version is unsupported (no right-sidebar service) Since 0.2.3 this plugin no longer supports older DSH versions. The right-sidebar plugin services
sidebarRightTabs/sidebarRightwere not detected, so the plugin exposes no entry point at all (the old floating ball and floating window have been removed) and will not start the IDE in the background. Upgrade DSH to 0.2.0-rc.2 or newer: Code Server then appears as a right-sidebar tab, this page shows the full settings again, and no reinstall is needed — a page refresh is enough.
- Where the notice lives: it is a read-only block on the only settings seat (
plugins.bundle.config, driven by theconfigFormschannel); an old-generation DSH has neither, so on those deployments all that is left is the console warning[code-server] 未探测到右侧栏服务…. Behaviour is unchanged: no IDE start, no entry point at all. - No other UI: no
shell.overlayregistration (floating ball), no file-address claim, no resident preload. - Host side: the client posts
/api/code-server/ui-mode { sidebar:false }; the host then ① stops auto-prestarting the IDE (maybePrestartreturns immediately) and ② recycles an instance it had just auto-prestarted (unless it was adopted), so no unusable IDE process or port is left behind. A user-started/adopted instance is never stopped. - Why delete instead of keeping: the internal floating window was a stopgap from the era of early-2026 DSH builds
without right-sidebar services. The resident surface, clipboard handling, shortcuts and panel collapsing all build on
DSH's right sidebar, so maintaining two carriers costs more than it is worth. The old-generation compatibility
branches (the rc / alpha lines' seats, channels and the
currentfallback) were deleted on 2026-10-01 as well — users who still need an old-generation DSH (≤ 0.1.7-rc.x) should pindsh-code-server-app@0.3.69(dsh plugin --profile web add dsh-code-server-app@0.3.69), the last version that still carries those branches. - Rollback: for an old-generation DSH, drop to
0.3.69(dsh plugin --profile web add dsh-code-server-app@0.3.69); on a 0.2-generation DSH no rollback is needed.
code-server workspace and process lifecycle
- code-server's workspace follows the active DSH session/workspace: switching sessions/workspaces while the IDE is open moves code-server to the new directory
(resolution order: current session cwd → session's
workspace.path→ workspace of the most recently active session → first workspace.path; there is only one source for "the current session": the session-scoped standard propsessionId(this 0.2 generation; the session-list snapshot'scurrentfallback used by the old rc line has been deleted with the old-generation branches) — see the 0.3.48 bullet below; the logic is inlined inlib/client.js(the "workspace resolution" section) and the contract is pinned byscripts/test-client-bundle-cwd.mjsdirectly against the entry file); the opened directory is shown inside code-server (?folder=<cwd>, the page reloads when following a switch); implementation note: the iframesrcmust carry?folder=<cwd>— code-server's front-end remembers the "last workspace" and restores it by itself; a bare root URL only shows the previously opened directory and does not follow switches (verified locally). Windows path format (verified): thefolderparameter must start with/and use forward slashes only, e.g./C:/Users/User/Desktop/biss; a bare Windows path (C:\...) is parsed as a URI scheme and the drive letter is stripped (page shows\Users\User\...with an empty file tree), whilefile:///C:/...reports "Workspace does not exist". - 0.3.48 fixes "opening Code Server no longer opens the matching workspace": DSH 0.1.6-alpha.2 removed
currentfromSessionListState(upstream refactor: view selection remains outside the Controller), while 0.3.46 and earlier read the current session fromuseSessions(s => s).current— so the cwd was always undefined, the client **stopped
…
Links
More in this category
tt-a1i/archify#integrations/deepseek-harness★ 81709
Generate validated, self-contained interactive architecture, workflow, sequence, data-flow, and lifecycle diagrams from repositories or system descriptions.
dream-num/dsh-univer-office★ 494
Give DeepSeek Harness a real office environment. Univer Office Plugin brings spreadsheets, docs, slides, canvases, relational tables, and more into one runtime — with connected data, validation, versioned changes, and isolated worktrees for multi-agent collaboration.
PerryLink/dsh-industry-research★ 214
Deterministic industry research reports for DeepSeek Harness — company and industry research flows produce structured, verifiable reports from staged evidence.
PolinniZhong/dsh-knit★ 53
Lists the Markdown documents, images and video that already exist anywhere in the session workspace in the DSH sidebar, ranked by relevance to the current conversation: recent messages are matched locally against document title, summary and body with IDF weighting, with no model calls and no network. Because the list is scanned from the workspace instead of remembered, restarting DSH or starting a new session does not empty it. Images and video preview in place, with relative-path images resolved and video streamed over HTTP Range. A references bar under the preview header shows which documents cite the one being previewed and which it cites, with one click to jump between them. The same ranking is exposed to the agent as a knit_docs tool, which returns the most relevant documents along with the passage that matched in each, where one is found.
HuanLinOTO/dsh-plugin-mineru★ 46
Expose MineRU document parsing tools to the model.
kw78/dsh-office-tools★ 28
Workspace-safe Office tools for agents: create/read Word, create/read/update Excel, and create/read PowerPoint decks with PNG/JPG/GIF image placement.
Community comments
Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.