DeepSeek Harness Plugin

jinsiyu/dsh-code-server-app

Stars ★ 5 Downloads (30d) 14,238 Category Docs & Rendering Added 2026-08-27 npm dsh-code-server-app

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 / homepage in package.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 .vsix from the Marketplace page and install it manually with code-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 requires react-dom/client and @deepseek-ai/dsh-client-ui-primitives straight 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 on ctx property 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 and ctx.get(name) second, so either context shape registers;
    • when the sync probe already sees the services but inject never 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".
  • 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.sidebarRight only has isExpanded/toggleExpanded (expand/collapse), while push ⟷ fullscreen is recorded in ui-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 with closest('[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 one console.warn — panel rendering is never affected.
  • 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/status still returns env for scripts). The old windowedOpen (open in a window) and reserveComposer were 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>/; under serve: dsh it 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, .py and *.py are 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 as Makefile, 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 (bat cmd ps1 sh py js…) and csv / tsv still 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;!pdf back 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".
    • 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's Config default and the client's canOpen share 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's parseFileAddress), expands a workspace-relative path with that session's cwd, and posts the absolute path (plus optional line) to the host's /api/code-server/open-file; the bundled extension (dshcs-open-file) then calls showTextDocument (positioned at the line when given).
  • A single tab (since 0.3.57): DSH's own semantics are "one address = one tab" (contentId is the address — same address is idempotent, a different one always opens a new tab), and replaceTab can only be passed by the opener (the product's own openFile/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 (a ref pins "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 of lib/client.js, and a tab is merely its docking host (Element.moveBefore). Regression: scripts/test-client-bundle-tabs.mjs renders 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 without actions does 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 calls showTextDocument, 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 plain appendChild move resets the iframe's internal timers (i.e. reloads it), while Element.moveBefore() (Chromium ≥133) preserves state (a probe counter keeps counting 1→2).
  • Degradation is never silent: when moveBefore is missing, or the host was already detached by React and it throws HierarchyRequestError: invalid hierarchy (passive effect cleanup runs after DOM removal), the code falls back to appendChild — one reload, but the frame is never lost — and reports degraded / lastMoveError so the UI can say "residency unavailable".
  • Repaint fallback (measured): in the real GUI the surface was seen once with correct size, hit testing and visibility that simply stopped repainting (a fully white panel, byte-identical screenshots proving no new frame). translateZ(0) and opacity nudges did nothing; display:none → forced reflow → restore inside 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 offscreen moveBefore park 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 one nudgeRepaint() (counted as surfaceSnapshot().nudgeCount; setNudgeEnabled(false) A/Bs it live).
  • Warm-up: with keepResident (default true) 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: port defaults to 0 → 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. Pin port explicitly 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 from location.pathname (the same mechanism already proven by mounting under /code-server/ in serve: 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 an Origin header and therefore slip past the Origin == Host check.
  • Referrer-Policy: no-referrer: the token lives in the path, so it must not leak through Referer when 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.serve in cordis.patch.yml or 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, so sandbox is dropped there (same-origin plus allow-same-origin is escapable by the frame itself; in loopback mode the iframe is cross-origin and sandbox stays as real protection — clipboard is still granted via allow="clipboard-read; clipboard-write"); and forwarded-port WebSockets cannot be routed because registerUpgrade matches exact paths while /proxy/:port carries the port in the path (HTTP forwarding works; use loopback when you need WS forwarding).

  • In loopback mode every upgrade passes a code-server-equivalent Origin check (since 0.2.1): when an Origin header is present its host must equal Host (honouring Forwarded: host= / X-Forwarded-Host, like code-server), otherwise the handshake gets 403; non-browser requests without Origin are allowed. Without that check any local browser page could complete a handshake against ws://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") accepts queue (the default) or steer. 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 calls agent.steer() and the follow-up is consumed at the running turn's next step boundary (answered within that turn); queue ⇒ the host calls agent.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 no agent.steer, delivery falls back to queue — 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 while queue is 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 (MarkdownText from @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 DisclosureRow plus 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-once can 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-origin POST /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 own fs tools; the bridge only knows about them and carries your answer back.
  • Status bar shows $(plug) DSH while connected (click it for the log in the "DSH Editor Bridge" output channel).
  • The extension ships as a built-in: dshcs-editor-bridge is installed into <tree>/lib/vscode/extensions/ next to dshcs-open-file — an extension left in the user extensions folder is marked .obsolete (log line Marked extension as removed) by the VS Code server and skipped forever. To turn the bridge off use the plugin setting editorBridge=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)

  1. Desktop has no HTTP surface at all: the renderer calls host.fetch() through Electron IPC (createSharedFetchHandler('/api') in apps/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's webServer.
  2. /api cannot carry it either: Connection puts a Host/Origin/cookie fence on /api (requestRejection in packages/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/sync returned either 405 (it reached the launcher/VS Code) or 401 (the fence) — the bridge had never actually synced.
  3. 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 webServer prefix — 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:

  1. /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 in scripts/test-bridge-routes.mjs guards 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).
  2. 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) id must belong to a request this process created and that is still pending (single use); (c) outcome accepts only allowed-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's approval/request itself fails closed; this bridge can only keep "nobody answered" as "nobody answered"). pnpm test:ask-dialog asserts these four plus the host-side whitelist.
  3. Any request carrying Origin gets 403. Browsers always send one (including a sandboxed iframe's literal Origin: 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.
  4. Paths are confined to the editor's current workspace folders.
  5. 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.mjs pins 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 / sidebarRight were 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 the configForms channel); 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.overlay registration (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 (maybePrestart returns 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 current fallback) were deleted on 2026-10-01 as well — users who still need an old-generation DSH (≤ 0.1.7-rc.x) should pin dsh-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 prop sessionId (this 0.2 generation; the session-list snapshot's current fallback 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 in lib/client.js (the "workspace resolution" section) and the contract is pinned by scripts/test-client-bundle-cwd.mjs directly against the entry file); the opened directory is shown inside code-server (?folder=<cwd>, the page reloads when following a switch); implementation note: the iframe src must 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): the folder parameter 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), while file:///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 current from SessionListState (upstream refactor: view selection remains outside the Controller), while 0.3.46 and earlier read the current session from useSessions(s => s).current — so the cwd was always undefined, the client **stopped

…

Content from the project README on GitHub ↗

Links

More in this category

View the whole category →

Community comments

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