DeepSeek Harness (dsh) for VSCodeRun the DeepSeek Harness (
All four surfaces share a single dsh session per workspace cwd (via
The rest of this document walks through each surface, the slash command surface that is shared across surfaces, the configuration keys, the wire protocol, the transports, and the few upstream-churn isolation layers that keep the extension working across dsh revisions. Table of contents
Installation
Engine floor: VSCode 1.95+ (the chat participant API stabilised there). Older versions get the rest of the extension minus the chat panel and the in-editor chat. Node ^18 || >=20. Quick start
To opt out of the auto-start, set SurfacesIn-editor chatA Why a custom webview (not Simple Browser) for chatSimple Browser is the right surface for the full dsh web UI —
dsh's loopback server renders correctly there because Simple
Browser honours Layout
The Trajectory tab replaces the messages + composer with a read-only event log; clicking Chat returns to the conversational view. State in the messages reducer is preserved across the toggle. Send / cancel (closed-loop)The composer has two modes: Send when idle, Stop when a turn
is in flight. Clicking Send fires Clicking Stop triggers the closed-loop cancel protocol (the v2 fix that replaced the earlier "fake stop" + lost trailing events):
A The Chat Participant ( Attachments ("Add to context")Click the + next to the attachment strip (or call
Each dialog is opt-in per choice: cancelling one never leads to
a second unwanted dialog (the previous sequential implementation
always showed the note input box, even after a file picker cancel —
the user complaint that prompted this redesign). Files become
Mention menus (
|
| Chip | Wire | Persisted to | Notes |
|---|---|---|---|
🔒 Permission (read-only / workspace-write / danger-full-access) |
commands/execute({line:'/permission <preset>'}) |
dsh.permissionPreset |
Picker shows 3 presets by default; dsh is the source of truth — chip reads permission/preset + sandbox/mode events from session history on bind and from the live mux while idle |
| 🤖 Agent | agentPreset.select({agentPreset: id}) |
dsh.agentPreset |
dsh locks the preset on a session once it has produced events. The host surfaces this as a friendly toast (Click "New" to start a fresh session with the selected preset — your choice has been saved to dsh.agentPreset) and the choice is persisted for the next /new session |
| ⚙ Model | session.selectModel({provider, model}) |
dsh.model (as <provider>:<model>) |
Picker groups by provider; current effort override is preserved across the switch |
| ⚡ Effort | session.selectModel({provider, model, reasoningEffort}) (carrying the unchanged provider/model) |
dsh.effort |
Picker sorts ascending by reasoning depth (off < low < medium < high < xhigh < max); the synthetic "Default" row mirrors the adapter's defaultEffort.id and serves double-duty as a "clear override" affordance |
| 💬 Plan mode | session/projection {key: 'plan'} mux frame → commands/execute({line:'/plan' or '/plan off'}) |
(read-only chip) | The chip is read-only — it reflects dsh's authoritative plan mode. Clicking when active → exit plan mode (/plan off). When inactive the chip is hidden. Seeded on bind from a history scan (extractPlanModeFromHistory); updated live via the bind-time mux downlink |
In-flight switch guards prevent rapid-click storms from racing
dsh's cordis plugin graph (parallel agentPreset.select calls
would tear down the HTTP server — the crash that prompted this
fix). Each chip dimension has its own guard, so permission and
agent switches can run concurrently.
Session picker / binding lifecycle
Clicking the header session pill (or typing /pick in the
composer) opens a QuickPick-style session picker anchored to the
click point. The picker reads from a shared SessionListCache —
every panel and the sidebar share one cache per base URL, so a
brand-new session lands in every surface within one tick.
The picker shows every dsh session this machine knows about:
title from projections.values.title, cwd as the description,
most-recently-updated first. The current session is highlighted.
Picking one fires switchSession → switchSessionInto on the
host, which:
- Aborts the in-flight turn (if any).
- Re-binds the
ThreadSessionResolverto the new id under the workspace'scwdKey(cwd)(normalised — Windows backslashes + case + trailing slash all collapsed, so the sidebar and the picker land on the same binding). - Fetches
session.history(last 50 events) and posts them assessionHistoryBEFOREsessionInfo, so the chat shows the "Loading history…" placeholder for one frame and then fills cleanly (the reverse order would flicker for one frame). - Pre-scans the history for permission state and plan-mode projection so the chips settle to dsh's authoritative state before any tool calls render.
- Opens a long-running live-mux downlink so mid-session permission / sandbox / plan-mode flips from dsh (e.g. user flips the preset in dsh web) reach the webview while the panel is idle — not just while a turn is running.
The resolver map is persisted to globalState under
dsh-vscode.threadSessionMap.v2 (cwd → sessionId), so closing and
reopening the panel (or restarting VSCode) restores the last-bound
session instead of falling back to stableSessionIdFor(cwd).
globalState (not workspaceState) is used so the map survives
the "no folder open" and "workspace folder removed" cases that
wipe workspaceState.
Trajectory tab
Toggle via Trajectory in the header. Renders a read-only event log of every wire event the panel has seen — text deltas, tool calls, tool results, asks, approvals, info bubbles, errors. Useful when the conversational view hides context you want to inspect (e.g. a tool call that didn't render visibly).
Save trajectory → pops a save dialog
(dsh-trajectory-<ISO>.log default name), writes via
vscode.workspace.fs.writeFile, posts an info envelope
confirming the path. Cancel returns silently.
Ask / approval modals
When dsh pauses a turn to ask the user a clarifying question
(question/requested mux frame) or to ask for approval before
running a sensitive tool (approval/requested), the host forwards
the frame as a *Requested envelope. The webview renders the
modal with a defer effect — the modal pops up only AFTER any
in-progress assistant block has finalised, so the user sees the
full thinking block first, then the question.
| Surface | Question UX | Approval UX |
|---|---|---|
| In-editor chat | Full modal: title from q.question, options rendered as radio cards (single-select) or checkbox cards (multi-select) or text input (no options). Custom answers live in <note> blocks. ESC / scrim click = dismiss (no commit, rpcId recorded so the scan effect doesn't re-pop). Submit / Cancel = commit (inline card re-mounts locked). |
Full modal: tool name + reason + Allow / Reject buttons. |
Chat participant (@dsh) |
response.questionCarousel(...) — VSCode-native single/multi-select or text input. Empty answers[] (user dismissed) → { ok: false, error: { code: 'cancelled' } }. |
response.confirmation(...) chip with native Accept / Reject. Decisions come back on the next turn via acceptedConfirmationData[] / rejectedConfirmationData[], matched back to the rpcId via pendingApprovals. |
Both surfaces forward answers / approvals over HTTP /api/respond
(the mux is a server-push downlink only and closes on any client
message, code 1008). The host lifts sessionId into the
result.value payload — omitting it returns not-pending from
dsh's schema validator.
Chat participant (@dsh)
VSCode's chat panel gets a dsh participant alongside Simple
Browser and the in-editor chat. Open the chat panel (View → Chat,
or workbench.action.chat.open), type @dsh <message>, and the
prompt is sent to dsh via the same HTTP API as the other surfaces.
How it works:
- One dsh session per chat thread, lazily created on the first message (keyed by the workspace cwd today; the real VSCode thread id lands in a future phase).
- The handler fires
session.prompt, then opens the dsh/api/events.muxWebSocket to stream the assistant's reply directly into the chat panel as text frames arrive. The consumer (src/chat/ChatParticipant.ts → consumeStreamInto) accumulates every event into a single trailing(kind, chunk.index)slot — the same shape dsh's own web UI uses forblocks[chunk.index]. - Per-block-flush streaming with a periodic 120 ms timer.
The trailing slot accumulates deltas; the consumer renders the
trailing slot as ONE complete snapshot at flush time. Flush
triggers:
block-end-text/block-end-reasoning(slot is done), a different(kind, chunk.index)arriving (model moved on),tool/call/tool/result(flush first so the tool line lands between content that triggered it and the next content),turn/end(flush whatever's still buffered), stream close, or theSTREAM_MAX_EVENTScap. Between these boundaries a 120 mssetIntervalflushes the trailing slot so the user sees real-time updates even when the model keeps emittingtext-deltawithout ablock-endarriving. Tokens arrive faster than the interval so back-to-back deltas coalesce into the buffer before the next tick — the timer never fragments a single token. Each emit is ONE complete snapshot of the trailing slot at flush time, so the chat panel keeps ONE part per block rather than fragmenting across many parts; the earlier "stable-prefix / paragraph-level" shape produced too many parts and visually fragmented the panel. - Reasoning renders as a plain blockquote (
> …for every line, no decorative label). VSCode Chat's markdown sanitizer strips<details>/<summary>HTML, so the strongest visual distinction chat markdown supports is a blockquote — every paragraph of the chain-of-thought is wrapped in>so multi-paragraph reasoning keeps its structure, code fences inside still render correctly, and the blockquote left-rule is enough visual distinction from the answer text. - Tool events surface as
**<name>** \`` fortool/calland italic*<preview>*/*error: <code>*fortool/result. File-targeting tools (read/write/edit/view/create_file/str_replace/multi_edit/cat/notebook_edit) also emit aresponse.reference(uri)chip that opens the file in VSCode on click — matching Claude Code's VSCode extension UX. Tool-call deltas onassistant/chunkare skipped in favour of the assembledtool/callevent. - Three-layer dedup for the 3× same-content emit that dsh does
for streaming providers (
text-deltachunks +block-end-textassembled block +assistant/messageassembled message). Per-indexflushedText/flushedReasoningSets drop a lateblock-endfor a slot already emitted as its own part. The buffered-text check on theassistant/messagebranch (currentBlock?.kind === 'text') drops the message when streaming text is still buffered. ThesawAnyTextflag gates any furtherassistant/messagefor the rest of the turn.turn/endresets all three guards so the next turn (possibly non-streaming) starts fresh. - Closed-loop cancel. VSCode's built-in Stop button →
token.onCancellationRequested→ POST/api/session.cancel→ wait for{ accepted: true }→ let the stream reader live so it picks up trailing chunks +turn/end. Same v2 fix as the in-editor chat. - Slash commands are dispatched through the same
BEHAVIORSregistry the in-editor chat uses, so/new//permission//agent//model//effortproduce the same UX shape (QuickPick picker in chat, chip picker in editor) and the trailing-text fall-through (/new Hellodoes both the bind and the prompt send in one round-trip). - Reference filtering. VSCode auto-injects
@<id>: <label>lines for global rule files, chat customizations, implicit context, and MCP tool descriptions. The handler filters them out viadsh.chatExcludeReferenceIdPatterns(default prefixes:vscode.instructions.,vscode.customizations.,vscode.implicit.,Browser— the last one matches the chrome-devtools MCP tool'sBrowser Pagesid). User-attached references (file,symbol, …) use short semantic ids that don't collide and pass through unchanged. Setdsh.chatIncludeReferences: falseto skip the whole block. Setdsh.chatDebugReferences: trueto dump every reference id and the FULL final prompt into the OutputChannel — useful for discovering new prefixes to add. The handler also liftssessionIdintoresult.valuefor question / approval answers (without it, dsh returnsnot-pendingfrom the schema validator). - Disabled via
dsh.chatParticipant: false. The participant id isdshby default (overridable viadsh.chatId). - Transport is
dsh.chatStream:websocket(default, recommended) orpoll(fallback for networks where the mux WS is blocked or unstable).
Simple Browser (dsh web)
The full dsh web UI is rendered via VSCode's built-in Simple
Browser view. dsh.openPanel is idempotent — it scans every open
tab group for a Simple Browser tab pointing at the dsh URL
(matched by URL fragment, with/without trailing slash, bare
authority, URL prefix, authority+slash substring, page-title
labels that embed the authority, or the DeepSeek Harness
manifest name) and reveals it instead of opening a new one.
Without this, repeated menu / status-bar clicks would spawn one
tab per click. Toggle the reuse via dsh.reuseSimpleBrowserTab
(default true).
Why Simple Browser: dsh's /api browser-trust fence refuses any
request whose Origin does not equal the request's Host. A
custom webview has its own origin (vscode-webview://... or
https://*.vscode-cdn.net) and would be rejected. Simple Browser
handles loopback origins natively and is the supported way to
render localhost inside VSCode.
If simpleBrowser.show ever fails to load (URL or Uri variant),
the host falls back to vscode.env.openExternal(...) so the panel
still opens, just in the OS browser.
Sessions sidebar
Activity bar → dsh-vscode → Sessions. A TreeView (not
just a provider — the TreeView gives us reveal({ select: true })
for the click-to-focus flow) of every dsh session this machine
knows about. The provider watches both <dshHome>/sessions and
the optional secondary dsh.sessionsHome for
**/*.{json,jsonl,jsonl.zstd} (file-level + dir-level watcher,
150 ms debounce to coalesce mux frames).
Each row carries:
- Title from
projections.values.title, or?when dsh hasn't projected one yet. - Subtitle =
<relative time>(5s/3m/2h/4d/3w/Mar 14) pluscwd(basename). - Tooltip with the full path + session id.
- Icons drawn from editor-level theme colors:
- 🟢 running — dsh reports
running: true - 🔵 current — the in-editor chat or
@dshchat participant is currently bound to this session (driven byonInEditorSessionChanged→setCurrentSessionId) - ⬜ idle (outline) — neither running nor current
- 🟢 running — dsh reports
Click semantics:
- Left-click →
dsh.focusEditorChat→ reveals (or creates) the in-editor chat panel and binds it to the picked session. - Right-click → QuickPick menu:
- Open in dsh web UI (
dsh.openInDshWeb) — jump to the panel; dsh web doesn't support URL deep-linking, so the user picks the session there. - Locate Session File on Disk (
dsh.locateSessionFile) — reveal the JSON log in Explorer / Finder /xdg-open. - Copy Session ID (
dsh.copySessionId).
- Open in dsh web UI (
The setCurrentSessionId call uses fireImmediate for per-row
updates and a deferred workbench.action.focusActiveEditorGroup
to suppress the activity-bar focus flash. Reveal uses
{ select: true, focus: true } so the row is highlighted AND the
keyboard focus moves with it.
Status bar
Bottom-right of VSCode. Shows the dsh server lifecycle:
| State | Label | Behaviour |
|---|---|---|
stopped |
$(circle-slash) dsh stopped |
click → dsh.openPanel (probes + spawns) |
starting |
$(loading) dsh starting… (with rotation) |
click → no-op (or dsh.openPanel for fresh attempt) |
running (reused) |
$(debug-start) dsh :3080 (reused) |
click → dsh.openPanel (reuses existing Simple Browser tab) |
running (spawned) |
$(debug-start) dsh :3080 (spawned) |
click → dsh.openPanel |
running (spawned-os-assigned) |
$(debug-start) dsh :<port> (spawned) |
click → dsh.openPanel; port from 0 OS-assigned |
lost |
$(warning) dsh disconnected |
click → dsh.openPanel (probes for replacement) |
The status bar polls the server state every 1 s; changes are
reflected on the next tick. The lifecycle enum lives in
src/server/ServerState.ts.
Slash commands (unified)
The host-side dispatch table
(src/chat/chatSlashDispatch.ts)
maps each /<id> to a concrete handler. Every entry in this
table works in the @dsh Chat Participant AND in the in-editor
chat's / popup — they share dispatchSlash() / the BEHAVIORS
registry. Popup discovery comes from the same refreshRegistryFor()
that merges local entries with whatever dsh ships over
commands.list / agentPreset.list / skill.list.
Categories:
- Extension-owned (
SLASH_DISPATCHkeys) — host has special semantics dsh's wire doesn't offer. - Popup-only (
POPUP_ONLY_ENTRIES) — picked by the webview directly without a host roundtrip. - Chip-equivalent —
/permission,/agent,/model,/effort. DSH ships these itself viacommands.list; the slash form and the chip click converge on the same picker UX. - DSH-side text slash (
/goal,/plan,/feedback,/export) — forwarded to dsh viacommands/execute.
| Slash | Surface | Where handled | Effect |
|---|---|---|---|
/new |
both | extension | Force-create a brand-new dsh session for the current workspace and bind it. Trailing text falls through to the regular prompt send — /new Hello does both the bind and the send in one round-trip |
/compact |
both | extension + dsh | Ask dsh to compact this session's older conversation history. Forwarded via commands/execute to dsh's command-compact plugin (which does the work in-process — we never compress locally). Trailing text falls through to the regular send. Special-cased so "no compactable history yet" doesn't render as a success — uses classifyCompactOutcome to map the host's response to a clean status string |
/clear |
editor | extension | Clear the visible message list in the in-editor chat. The session history on dsh is kept — only the local view is reset. (Disabled in the chat participant — see note below) |
/panel |
both | extension | Open the dsh web panel in Simple Browser for the current workspace's session. Reuses an existing tab if one is open (same logic as dsh.openPanel) |
/refresh |
both | extension | Re-fetch the command / skill / preset registry from dsh. Use when a new command was added upstream but the popup still shows a stale list |
/pick |
both | popup-only | Open a quick-pick listing every dsh session dsh knows about. Selecting one binds the chat thread to it. No trailing text accepted — falls through to the regular send |
/switch <id> |
both | extension | Bind the chat thread to a specific dsh session by id. Validates against dsh's session list and warns when not found; use /pick to choose visually. Empty trailing text falls through to /pick |
/permission [preset] |
both | chip-equivalent | Switch the dsh permission preset. With no args opens a quick-pick of the three dsh presets (read-only / workspace-write / danger-full-access); with a valid preset id as args applies it directly. Same wire as the in-editor chip click |
/agent [preset] |
both | chip-equivalent | Switch the dsh agent preset. No args → roster quick-pick from agentPreset.list; valid id as args → applies directly. dsh locks the preset on a session once it has produced events; the choice is persisted for the next /new session in that case |
/model <provider>:<model> |
both | chip-equivalent | Switch the model. No args → provider-grouped quick-pick from session.models; <provider>:<model> as args applies directly. The current reasoning-effort override is preserved |
/effort [level] |
both | chip-equivalent | Switch the reasoning effort for the active model's current adapter-advertised levels. No args → quick-pick; valid id as args applies directly. Empty id → "Default" (clears override; dsh reverts to adapter default) |
/goal <text> |
both | DSH-side text | Set a long-running goal on the active session — dsh keeps working toward it across turns. Trailing text is the goal (e.g. /goal make every test in src/ pass) |
/plan [text] |
both | DSH-side text | Enter plan mode — dsh drafts a plan and waits for approval before executing. Clicking the Plan-mode chip when active exits plan mode (/plan off) |
/feedback <text> |
both | DSH-side text | Record feedback for the active session. Trailing text is the feedback (e.g. /feedback too many tool calls in turn 3) |
/export |
both | DSH-side text + extension | Export the active session's log to disk. The slash RPC returns "download requested"; the host follows up with GET /api/session.export?sessionId=…&includeDescendants=true and streams the ZIP into a user-chosen save dialog (session-<shortId>-<ISO>.zip default name), then revealFileInOS highlights it in the file manager |
Note —
/clearand/initare deliberately surfaced asUnknown command /<id>in the chat participant. dsh has no/clearor/initof its own, and the old handlers were misleading no-ops (clearing the chat-thread → session binding did nothing becausesession.createis idempotent onsessionId + cwd, so the next prompt re-resolved the same active session). The slash rows remain registered inpackage.json > contributes.chatParticipants[].commands[]so the protocol surface is stable. The/clearslash still works in the in-editor chat — there it does reset the local view (session history kept on dsh). Re-enable when dsh ships/clearor/initand we can forward to them.
Trailing prompt text after a slash command is preserved —
@dsh /new Hello does both the bind and the prompt send in one
round-trip. Returning early after the slash command would silently
drop the trailing text. The implementation builds the prompt up
front and falls through to the regular send flow when the prompt
is non-empty.
All commands (palette + menus)
Registered in package.json > contributes.commands[]. The
"category" prefix in the palette is dsh-vscode.
| Command | Title | Description |
|---|---|---|
dsh.openPanel |
Open Panel | Reuse-or-launch + Simple Browser; reuses an existing dsh tab if one is open |
dsh.startServer |
Start Server (force spawn) | Always spawn a new dsh web instance (skips reuse probe) |
dsh.stopServer |
Stop Server | No-op if reused; SIGTERM tree (or taskkill /T /F on Windows) if owned |
dsh.restartServer |
Restart Server | Stop + spawn |
dsh.setApiKey |
Set DEEPSEEK_API Key | Store the DeepSeek API key in VSCode SecretStorage (dsh.deepseekApiKey) |
dsh.clearApiKey |
Clear DEEPSEEK_API Key | Delete the stored DeepSeek API key |
dsh.sendSelection |
Send Selection to dsh (with prompt) | Editor context menu (when editorHasSelection); pops a prompt input — selection sent as context, agent waits for the user's question. Empty prompt sends the selection alone. Disable the prompt via dsh.confirmBeforeSend: false |
dsh.addPath |
Add Path to dsh (with prompt) | Explorer context menu (file scheme); same prompt flow, paths sent as @relative/path references |
dsh.openWithFolder |
Open AS DSH Workspace | Explorer context menu (folder or file → its parent dir); reuses the most recently updated session on that cwd or creates a new workspace-bound one, then opens the dsh panel |
dsh.openInEditorChat |
Open dsh Chat in Editor | Reveal the in-editor chat panel for the current workspace, or create + open it if no panel exists yet. Editor title bar icon. Disables in the palette when the in-editor chat is already focused (so the palette entry doesn't shadow the existing tab) |
dsh.focusEditorChat |
Open in Editor Chat | Reveal (or create) the in-editor chat panel and bind it to the given session id. Fired by left-clicking a session row in the activity-bar Sessions tree; right-click uses dsh.openInDshWeb instead |
dsh.openInDshWeb |
Open in dsh web UI | Right-click action on a session row: open the dsh web UI in Simple Browser and prompt the user to pick the session there. dsh web does not support URL-based deep-linking |
dsh.locateSessionFile |
Locate Session File on Disk | Right-click action on a session row: reveal the on-disk session log file in the OS file manager (Explorer / Finder / xdg-open) via revealFileInOS |
dsh.copySessionId |
Copy Session ID | Right-click action on a session row: copy the session id to the clipboard |
Menu bindings
| Menu | Commands | When |
|---|---|---|
editor/context |
dsh.sendSelection |
editorHasSelection |
editor/title |
dsh.openInEditorChat (icon: resources/icon.svg) |
always |
explorer/context |
dsh.addPath, dsh.openWithFolder |
resource && resourceScheme == file; explorerResourceIsFolder |
commandPalette |
dsh.sendSelection, dsh.addPath, dsh.openWithFolder |
(mirror of the when clauses above — the palette hides commands that wouldn't apply to the current context) |
view/item/context (Sessions tree) |
dsh.openInDshWeb, dsh.locateSessionFile, dsh.copySessionId |
view == dsh-sessions && viewItem in {session, session-active, session-running, session-active-running} |
Configuration (dsh.*)
| Key | Type | Default | Notes |
|---|---|---|---|
dsh.executable |
string | npx |
pnpm, absolute path, etc. |
dsh.executableArgs |
string[] | [--yes, @deepseek-ai/dsh, web] |
Override to use a local checkout (['dsh', 'web'] with dsh.executable='pnpm') |
dsh.host |
string | 127.0.0.1 |
Loopback only — the dsh /api browser-trust fence rejects non-loopback |
dsh.port |
number | 3080 |
0 disables reuse probe (OS picks a free port each launch) |
dsh.reuseExisting |
boolean | true |
Probe before spawn — adopt a running dsh web instead of forking |
dsh.probeTimeoutMs |
number | 800 |
TCP + manifest deadline |
dsh.dshHome |
string | ${userHome}/.dsh |
DSH_HOME for the spawned dsh process; the Sessions sidebar scans this directory |
dsh.sessionsHome |
string | "" |
Optional secondary session root the Sessions sidebar scans alongside <dshHome>/sessions; useful when ~/.dsh is shared between this extension and a separate dsh install |
dsh.startTimeoutMs |
number | 120000 |
How long to wait for the dsh web readiness line on stdout (bump if @deepseek-ai/dsh is slow to download on first run) |
dsh.trustedHosts |
string[] | [] |
Extra --trusted-host values passed to dsh web |
dsh.autoStart |
boolean | true |
Probe + start the dsh web server on activation. Reuse probe runs first, so an already-running dsh in another window is adopted instead of forked. Set false to opt out |
dsh.jsonrpcBridge |
boolean | auto | Opt-in JSON-RPC bridge; auto-enabled when dsh.jsonrpcAgentBin resolves and dsh.jsonrpcConfigPath is empty or points at an existing file. Set explicitly to override |
dsh.jsonrpcAgentBin |
string | "" |
e.g. node <repo>/packages/examples/jsonrpc-demo/lib/bin.js (the only viable option on Windows — the upstream dsh-jsonrpc-agent-pkg exe targets exclude win32) |
dsh.jsonrpcConfigPath |
string | "" |
Path to the cordis.yml used by dsh-jsonrpc-agent; leave empty to use the bundled template |
dsh.confirmBeforeSend |
boolean | true |
Show a prompt input before sending selection/path; set false for scripted workflows that want immediate auto-submit |
dsh.reuseSimpleBrowserTab |
boolean | true |
dsh.openPanel reuses an already-open Simple Browser tab (matched by URL fragment or "DeepSeek Harness" page title) instead of opening a new one; set false for a fresh tab on every invocation |
dsh.chatParticipant |
boolean | true |
Register the @dsh chat participant in VSCode's chat panel |
dsh.chatId |
string | "dsh" |
Override the chat participant id (the bit you type before your question, e.g. @<id> ...) |
dsh.chatStream |
"websocket" | "poll" |
"websocket" |
Transport for the chat panel's incremental render — websocket (default) opens the dsh mux WebSocket and streams assistant text into the chat panel as it arrives; poll skips streaming and shows a single Done. line after the session goes idle. Set to poll if the WebSocket connection is blocked or unstable in your network setup |
dsh.chatIncludeReferences |
boolean | true |
When true (default), VSCode chat references the user attached (#file:, #symbol:, variables, …) are rendered as @<id>: <label> lines and prepended to the prompt text sent to dsh. Set to false to send only the user's typed prompt |
dsh.chatExcludeReferenceIdPatterns |
string[] | ["vscode.instructions.", "vscode.customizations.", "vscode.implicit.", "Browser"] |
Reference-id prefixes the chat handler filters out before sending to dsh. Defaults cover Claude Code global rule files, chat customizations, implicit context (active selection), and the chrome-devtools MCP tool (whose wire id is Browser Pages). Add more prefixes to suppress additional auto-injected refs |
dsh.chatDebugReferences |
boolean | false |
When true, dumps every reference id (with label head) and the FULL final prompt into the OutputChannel for the user to inspect. Workflow: enable → send a chat message → read the leaked id off the log → add a matching prefix to dsh.chatExcludeReferenceIdPatterns → turn this back off. Default false — production traffic should not spam the OutputChannel |
dsh.agentPreset |
string | "" |
Default agent preset id for the in-editor chat's preset chip; persisted on every click. Empty (default) lets dsh pick its built-in default |
dsh.permissionPreset |
enum | "workspace-write" |
In-editor chat's permission chip label + default. Runtime is changed by dsh's /permission; the setting is just the last pick |
dsh.effort |
string | "" |
Default reasoning-effort id; persisted on every click. Empty = "Default" (use adapter default for the active model) |
dsh.model |
string | "" |
Default model in <providerId>:<modelId> form; persisted on every click |
Wire protocol
The in-editor chat webview ↔ host bridge is a discriminated-union
wire protocol whose single source of truth is
src/chat/inEditorChat.webview/messages.ts.
Both sides import the same file (host as .ts, webview as bundled
.js), so they cannot drift.
Host → webview (ToWebview)
| Envelope | Trigger | What the webview does |
|---|---|---|
sessionInfo |
bind / switch / new session | Set the bound session id, source, cwd, optional title. Mirrored into state.sessionId so chip handlers know which session to target |
noSession |
bind failed | Render the empty-state with the failure reason |
sessionHistory |
after sessionInfo |
Replace the messages list with the fetched events (first 50 from session.history); tail: true clears prior-session state |
event |
live mux push | Pass-through: every assistant/chunk, tool/call, tool/result, user/message, turn/start, turn/end, session/event lands here verbatim and the webview's foldSessionEvent primitive renders it |
done |
end of turn | Re-enable composer, clear busy state |
error |
turn failed / RPC rejected | Render as an error bubble |
info |
info notice (turn interrupted, save succeeded, …) | Render as an info bubble |
clear |
/clear |
Empty the local messages list (session history on dsh is kept) |
compactResult |
/compact finished |
Show the classified outcome (success / no-history / error) |
stats |
reserved | Token-usage breakdown (currently unused) |
pickedContext |
host resolved an "Add to context" picker | Append the attachments to the attachment strip |
mention_files |
request_mention_files resolved |
Populate the @file mention menu |
mention_folders |
reserved | Folder expansion data |
sessionsAvailable |
requestSessions resolved |
Populate the session picker |
sessionHistory (continuation) |
"Load more" pager | Older events append to the bottom of the messages list |
registryAvailable |
refreshRegistry resolved |
Merge commands.list + agentPreset.list + skill.list into the slash popup; local entries win on collision |
registryUnavailable |
refreshRegistry failed |
Slash popup stays in the "empty" state with an offline hint |
questionRequested |
question/requested mux frame |
Render the AskApprovalModal; user answer POSTs answerQuestion |
approvalRequested |
approval/requested mux frame |
Render the AskApprovalModal; user answer POSTs answerApproval |
permission_mode_changed |
chip click echo / session-history pre-scan / live mux | Re-sync the Permission chip |
agent_mode_changed |
chip click echo / agentPreset.list refresh |
Re-sync the Agent chip |
effort_mode_changed |
session.models refresh / set_effort echo |
Re-sync the Effort chip + picker |
model_mode_changed |
session.models refresh / set_model echo |
Re-sync the Model chip + picker |
plan_mode_changed |
session/projection {key: 'plan'} mux frame |
Render / hide the Plan-mode chip |
Webview → host (FromWebview)
| Envelope | Trigger | What the host does |
|---|---|---|
ready |
webview mounted | Flush any buffered posts (from a sidebar left-click during webview load), seed permission-preset + agent-roster, bind session, refresh model directory |
send |
Send button / Enter | Compose prompt (folding attachments + chat references) → runTurn |
newSession |
/new chip / header pill |
runNewSession — force-create a fresh session |
compact |
/compact chip / slash |
runCompact — forward /compact to dsh via commands/execute |
slash |
popup click / typed slash | dispatchSlash(id, args, ctx) — extension-owned → local handler; DSH-side → commands/execute |
pickSession |
(deprecated no-op) | Kept so old webviews don't crash the host |
openFile |
click on a .opt-path-link inside a tool card |
vscode.Uri.file(raw.path) → showTextDocument with preview: false |
requestSessions |
header pill click | cache.ensureFresh({ force: true }) → sessionsAvailable |
switchSession |
picker row picked | switchSessionInto — abort in-flight + rebind + fetch history + open live mux |
cancel |
Stop button | Closed-loop session.cancel — see Send / cancel |
saveTrajectory |
Trajectory tab → Save | Pop save dialog → fs.writeFile → info confirm |
pickContext |
+ on the attachment strip | QuickPick ("Add files" / "Add a note" / "Add both") → pickedContext |
request_mention_files |
typing @ |
findFiles('**/*', MENTION_FILE_EXCLUDE, 1024) → mention_files |
answerQuestion |
AskApprovalModal Submit | POST /api/respond with AskUserQuestionAnswer envelope (carrying sessionId) |
answerApproval |
AskApprovalModal Allow / Reject | POST /api/respond with { approvalId, outcome } (carrying sessionId) |
set_permission_preset |
Permission chip picker | In-flight guard → commands/execute('/permission <preset>') → write dsh.permissionPreset |
set_agent_preset |
Agent chip picker | In-flight guard → agentPreset.select → write dsh.agentPreset; handle agent-preset-locked with a friendly toast |
set_effort |
Effort chip picker | In-flight guard → session.selectModel (carrying current provider+model + new reasoningEffort) → write dsh.effort → re-fetch directory |
set_model |
Model chip picker | In-flight guard → session.selectModel (preserves effort) → write dsh.model as <provider>:<model> → re-fetch directory |
The webview is sandboxed by a tight CSP
(default-src 'none', script-src 'self' ${cspSource},
connect-src 'none', frame-src 'none', …). It only postMessages
— every outbound call goes through api.ts > postToHost, every
inbound handler is keyed on a string discriminator, and the wire
types are validated by the discriminated union so an unrecognised
envelope is a type error on both sides.
Capability gate
dsh is in developer preview with frequent breaking changes. New
capabilities land as optional RPCs (skill.list, subagent.*,
settings.describe, …) on newer builds. The extension cannot
assume they exist; every call goes through a version-gate so the
UI degrades gracefully on older dsh.
DshCapabilities (src/sdk/gateway.ts) is built from
host.describe.version and exposes:
interface DshCapabilities {
hasSkillList: boolean // caps.methodAvailable(RpcMethod.SkillList)
hasAgentPresets: boolean // caps.methodAvailable(RpcMethod.AgentPresetList)
hasSubagentRpcs: boolean // caps.methodAvailable(RpcMethod.SubagentList) etc.
hasSearchRenameFork: boolean // session.search / .rename / .fork
hasSettingsDescribe: boolean // settings.describe → live secret-key overlay
methodAvailable(name): boolean // generic gate for any RpcMethod
}
The METHOD_SINCE table maps every RpcMethod.* to the minimum
dsh version that ships it. The comparison is semver-aware (so
0.1.0-rc.2 < 0.1.0 < 0.1.1 < 0.2.0). When
host.describe.version is unparseable (older dsh, the stdio
JSON-RPC bridge which doesn't serve the RPC, or a placeholder
0.0.1 build), the gate fails OPEN — every method is assumed
to exist. This is intentional: better to make a call and surface
a graceful "method not found" than to silently disable a feature
on a misidentified version. The caller still gets a bad-response
envelope when the method truly isn't there, which the UI surfaces
as an error bubble.
Capability-gated call sites:
| Gate | Used by |
|---|---|
caps.methodAvailable(RpcMethod.CommandsList) |
refreshRegistryFor — merge commands.list into the slash popup |
caps.hasAgentPresets |
refreshRegistryFor + the Agent chip's refreshAgentRoster |
caps.hasSkillList |
refreshRegistryFor — populate the skill entries |
caps.hasSettingsDescribe |
getEffectiveDshKeyMap — live overlay on top of the static DSH_KEY_TO_VSCODE |
Adding a new capability row: one line in METHOD_SINCE + one
field in DshCapabilities. No UI change unless the gate matters.
Transports
Two wire-level transports implement the DshGateway port
(src/sdk/gateway.ts):
HttpApiClient(loopback HTTP, always on). POSTs tohttp://127.0.0.1:<port>/api/*. This is what every surface uses by default — Simple Browser shares the same loopback server, the in-editor chat and@dshchat participant talk to it via the host. The WebSocket endpoint for streaming is/api/events.mux; the server is server-push only and closes on any client message (code 1008), so ask/approval answers go over HTTP/api/respond, not the WS.JsonRpcGateway(stdio JSON-RPC bridge, opt-in). Spawnsdsh-jsonrpc-agentas a child process and exchanges newline-delimited JSON frames over stdin/stdout. Only a subset of methods are stdio-routable today (STDIO_METHODSingateway.ts— currently justhost.describeandsession.prompt); everything else goes through HTTP even when the bridge is enabled. The bridge auto-enables whendsh.jsonrpcAgentBinresolves anddsh.jsonrpcConfigPathis empty or points at an existing file; setdsh.jsonrpcBridgeexplicitly to override.
Both transports implement the same DshGateway interface —
UI code depends on the port, not the wire, so adding a third
transport (gRPC, WebSocket-over-HTTP, …) is a one-file change.
Reuse-or-launch
Before spawning anything, the extension probes
127.0.0.1:<port> for a running dsh web. The probe is TCP + a
GET /manifest.webmanifest whose name must equal
"DeepSeek Harness" (the DSH_MANIFEST_NAME constant in
src/config/ConfigKeys.ts). TCP-only probes would falsely
identify any stray python -m http.server bound to the same port
as dsh; the manifest check is the discriminator.
If a match is found, the extension adopts it (status bar shows
running :3080 (reused)) and does not spawn. Closing the
panel keeps the user's own dsh web running.
If no match is found, the extension spawns a new child process
using dsh.executable dsh.executableArgs (default
npx --yes @deepseek-ai/dsh web). The readiness signal is dsh's
stdout line ^dsh web: http://127.0.0.1:\d+; the host waits up to
dsh.startTimeoutMs (default 120 s) for it before failing over.
Disposing the panel SIGTERMs the child; on Windows,
taskkill /T /F kills the tree. The session list refreshes when
the child exits so a stale spawned row in the sidebar doesn't
linger.
Decoupling from upstream churn
dsh is in developer preview with frequent breaking changes. The extension depends on exactly four transport-level surfaces:
- CLI argv —
dsh web [--host H --port N --trusted-host A] - stdout readiness line —
^dsh web: http://127.0.0.1:\d+ - Loopback HTTP —
/manifest.webmanifest,/,/api/* - JSON-RPC stdio wire (opt-in, isolated to
src/sdk/)
Internals like Cordis plugins, package layout, and TypeScript
internals are deliberately not imported. The optional JSON-RPC
bridge re-implements the wire protocol in-house rather than
depending on @deepseek-ai/dsh-sdk-client and its 5-peer package
graph.
On top of those four surfaces, the extension isolates three more sources of churn behind a port + capability-gate layer so that the UI doesn't track dsh renames one-to-one:
Wire port (
src/sdk/contracts.ts+gateway.ts). Every RPC method literal lives inRpcMethod.*; every per-method request / response shape lives next to it. TheDshGatewayinterface declares each method as a typed async function. Both transports (HttpApiClientfor loopback HTTP,JsonRpcGatewayfor the stdio bridge) implement the same port — UI code depends on the port, not the wire, so when dsh renames a method or splits a payload,RpcMethod.*+ the result type move once and the UI never sees it.Version gate (
DshCapabilities, built fromhost.describe.version). Anything that didn't exist on older dsh (skill.list,subagent.*,settings.describe, …) is checked viacaps.methodAvailable(RpcMethod.X)before the UI calls the port. TheMETHOD_SINCEtable insrc/sdk/gateway.tsis the single place to add a new capability row when dsh ships one. See Capability gate.Settings indirection (
src/config/ConfigKeys.ts+SecretStore). The dsh-side canonical secret key ('secrets.deepseekApiKey') resolves through a memoisingcreateDshKeyResolver()that merges the staticDSH_KEY_TO_VSCODEfallback with whateversettings.describereports on the live host. The static map is always authoritative for keys it covers — the user's saved secret atdsh.deepseekApiKeynever gets silently renamed behind their back, even if dsh ships a hostile overlay entry. The static map is consulted synchronously (soDshServer.spawnFreshcan readDEEPSEEK_API_KEYBEFORE the child exists, without deadlocking on the resolver's live upgrade).
Project layout
src/
config/ ConfigKeys (DSH_MANIFEST_NAME, DEFAULT_PROVIDER,
permission presets, DSH_KEY_TO_VSCODE +
createDshKeyResolver/getEffectiveDshKeyMap for the
live settings.describe overlay), readConfig
(typed dsh.* reader), resolveBin
secrets/ SecretStore (DEEPSEEK_API_KEY; resolves through
createDshKeyResolver when one is wired, falls back
to the static key otherwise)
server/ PortProbe, DshProcessManager, DshServer orchestrator,
isLocalDshAvailable, ServerState (lifecycle enum)
ui/ openPanel (simpleBrowser.show wrapper + tab-reuse
scan), statusFormat (status bar label builder)
sidebar/ SessionsProvider (TreeView + watcher + relative-time
formatter), SessionNode
statusbar/ StatusBarController (1s polling + click → openPanel)
commands/ one file per command (openPanel, start, stop, restart,
sendSelection, addPath, openWithFolder, setApiKey,
clearApiKey, openInEditorChat, focusEditorChat, ...)
sessions/ sessionTarget (pick-or-create by cwd + cwdKey
normaliser), SessionListCache (per-baseUrl), observer
(FileSystemWatcher-driven invalidation)
sdk/ contracts (RpcMethod + wire envelope + result types —
single source of truth), gateway (DshGateway port +
DshCapabilities version gate + METHOD_SINCE),
HttpApiClient (loopback HTTP transport implementing
DshGateway), JsonRpcGateway (stdio bridge transport
implementing DshGateway + the lower-level stdio wire
framing + HarnessClient lifecycle), contentBlocks,
registryBuilder (capability-aware command/skill/
preset registry)
chat/ ChatParticipant (`@dsh`, streaming + ask/approval
forwarding), eventStreamer (mux WebSocket reader),
requestAdapter (buildPromptText + reference filter),
sessionMap (ThreadSessionResolver + globalState
persistence), chatSlashDispatch (SLASH_DISPATCH +
POPUP_ONLY_ENTRIES + localSlashEntries),
registryRefresher (commands.list + agentPreset.list
+ skill.list merge), compactResult (classifyCompact
Outcome), exportSession (streamSessionExportToFile),
pathUtil (resolveFilePath);
inEditorChat.ts (WebviewPanel host — singleton +
bind/switch/runTurn/runNewSession/runCompact +
handleMessage switch + 4 chip handlers +
refreshModelDirectory + refreshAgentRoster +
bindOrReportNoSession + drainPendingAsks +
openBindMux + saveTrajectoryToFile + pickContextFiles
+ respondMentionFiles + answerQuestion + answerApproval
+ runHostSlashCommandImpl);
inEditorChat.webview/ (built separately by esbuild —
App.tsx, main.tsx, messages.ts, api.ts,
composerSpacer.ts; hooks/ (useChat), kinds/
(commands, popups), render/ (Header, Composer,
Trajectory, EmptyState, JumpToBottom, SessionPicker,
PresetPicker, ModelEffortMenu, AskApprovalModal,
assistant/, message/), state/ (slashBehaviors,
slashDispatch, planMode, popups, pickerArgs),
styles.css)
log.ts OutputChannel wrapper
extension.ts activate/deactivate (secret store, status bar,
sessions provider, observer, command registrations,
JSON-RPC bridge auto-detection, autoStart gate via
isLocalDshAvailable, prime shared dsh-context fetch)
test/ vitest unit tests (SecretStore, dshKeyMap,
capabilities, sessionTarget, eventStreamer,
consumeStream, ChatParticipant flow, webview
behaviors, picker mappings, ...)
resources/ sidebar icon + cordis.yml template
The in-editor chat webview is a self-contained Preact app under
src/chat/inEditorChat.webview/. The host (inEditorChat.ts)
manages the WebviewPanel lifecycle and the postMessage bridge;
everything below that directory runs inside the webview and is
built by a separate esbuild config (esbuild.config.mjs outputs
out/chat/inEditorChat.webview/main.js). The wire protocol
(FromWebview / ToWebview) lives in
./inEditorChat.webview/messages.ts so both the host and the
webview import the same source of truth. The webview is loaded
once per process via inEditorChat.webviewLoader.ts (cached
{indexHtml, styles} pair) with a per-webview CSP header.
Verifying a fresh dsh release
cd /path/to/deepseek-harness
git pull
pnpm install
pnpm run build # nothing changes; the extension only talks to stable surfaces
Reopen VSCode and run DeepSeek: Open Panel. As long as the
canonical readiness line and /manifest.webmanifest still resolve,
the extension keeps working. If dsh adds a new RPC the extension
needs, add it to RpcMethod + METHOD_SINCE + DshCapabilities,
then use caps.methodAvailable(...) at the call site — the
failure-OPEN gate keeps older dsh working in the meantime.
Troubleshooting & debug flags
| Symptom | Try |
|---|---|
| "fake stop" — composer comes back but the turn keeps producing | fixed in v2; if you see it on a brand-new build, check dsh.chatStream === 'websocket' and that the host's session.cancel POST lands (look for chat: session.cancel failed in the OutputChannel) |
| Trailing chunks lost after cancel | same v2 fix — the local stream reader is intentionally NOT aborted on success so it picks up the trailing chunks + turn/end; if you see this symptom, check the pollDone arm in runTurn is racing the streamer |
@Browser Pages: … line in dsh prompt |
add Browser to dsh.chatExcludeReferenceIdPatterns (already in the default) or set dsh.chatIncludeReferences: false |
Auto-injected @<id>: … line you can't identify |
enable dsh.chatDebugReferences: true, send a chat message, read the leaked id off the OutputChannel, add a matching prefix, disable the flag |
dsh.chatParticipant: false (or chat panel doesn't load on older VSCode) |
expected — VSCode < 1.95 doesn't ship the chat participant API; the rest of the extension works |
| Plan-mode chip never appears on a session that should be in plan mode | check the session/projection {key:'plan'} mux frame in the OutputChannel; the chip is gated on the bind-time history scan (extractPlanModeFromHistory) AND the live mux — a session whose plan was entered in another surface should still seed via history |
| Permission chip shows stale value on bind | check the bind-time extractPermissionState pre-scan in session.history; the chip falls back to dsh.permissionPreset only when no permission/preset or sandbox/mode events are in the page |
session.cancel rejected with agent-busy |
the session is backed by a subagent; the closed-loop cancel correctly surfaces this and aborts the local reader — the user can click Send again to start a new turn |
cordis / execute: fetch failed (HTTP 0) on rapid chip clicks |
the in-flight switch guards prevent this from happening — if you see it, check the guard state in state.abort / the per-chip pending*Switch flags |
JSON-RPC bridge won't auto-enable on Windows |
upstream dsh-jsonrpc-agent-pkg has no Windows exe; set dsh.jsonrpcAgentBin to node and configure dsh.jsonrpcConfigPath to point at a local checkout's cordis.yml |
| dsh web panel opens a new tab every time | dsh.reuseSimpleBrowserTab: true is the default; if a stale tab is being matched wrong, file an issue with the Simple Browser tab title |
The extension's OutputChannel is dsh-vscode — open it via
View → Output → dsh-vscode for host-side logs. The webview's
own console is in the Webview Developer Tools (Help → Toggle
Developer Tools → pick the webview's frame).
Known limitations
dsh-jsonrpc-agent-pkghas no Windows artifact. On Windows the JSON-RPC bridge usesnode <repo>/packages/examples/jsonrpc-demo/lib/bin.jsfrom a local dsh checkout. The main web UI works on Windows becausenpx @deepseek-ai/dsh webis portable.vsce packageonly skips theprepackagehook when invoked directly vianpx vsce package(which bypasses the package manager's lifecycle). Usepnpm run packageinstead -- pnpm's lifecycle runsprepackage(pnpm run build) for you, so the shipped.vsixnever contains staleout/artifacts.- dsh web does not support URL-based deep-linking — clicking "Open in dsh web UI" on a session row opens the panel and asks the user to pick the session there.
- The VSCode chat participant API is preview in 1.95–1.96 and stabilised in 1.97+. Engine floor is 1.95; if you see a participant-related console error on an old VSCode, the rest of the extension still works.
- npm
latestof@deepseek-ai/dsh-sdk-jsonrpc-demois stale (0.0.1-rc.1). Pin an explicit version (the working release is on the@nexttag) or use a local checkout. sendSelection/addPathalways route through HTTPsession.prompt(no WS streaming on these surfaces); a long turn will not show incremental progress until the session goes idle. Use the in-editor chat or@dshfor streaming./clearand/initare deliberately surfaced asUnknown command /<id>in the chat participant (dsh has no equivalent). They remain registered so the protocol surface is stable. The in-editor chat's/clearstill clears the local view (session history kept on dsh).