Skip to content
| Marketplace
Sign in
Visual Studio Code>Machine Learning>dsh-vscodeNew to Visual Studio Code? Get it now.
dsh-vscode

dsh-vscode

lunnlew

|
5 installs
| (0) | Free
Unofficial VSCode companion for the dsh agent runtime. Hosts the dsh web UI in Simple Browser, plus sidebar sessions, status bar, command palette, and editor context menu.
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

DeepSeek Harness (dsh) for VSCode

Run the DeepSeek Harness (dsh) agent runtime inside VSCode.

dsh-vscode does not reimplement the dsh UI. It spawns (or reuses) a dsh web instance on 127.0.0.1:3080 and surfaces it through four parallel surfaces, each tuned for a different task:

Surface Where Best for
In-editor chat (dsh.openInEditorChat) Editor tab Day-to-day Claude Code-style conversation — chips, slash popup, mention menus, attachments, ask/approval modals, trajectory tab
Simple Browser (dsh.openPanel) Editor tab Deep control — full session history, MCP settings, file tree, dsh's own slash-command console
@dsh chat participant Chat panel Quick conversational entry point from VSCode's built-in chat panel — streaming assistant text + reasoning + tool events + question carousels + confirmation chips
Sessions sidebar (dsh-sidebar) Activity bar Browse / switch / locate / open every dsh session this machine knows about

All four surfaces share a single dsh session per workspace cwd (via pickOrCreateSession) and the same in-memory session binding, so binding a session in the sidebar re-targets the in-editor chat and vice-versa. The status bar at the bottom of VSCode shows the current server lifecycle (running :3080 (reused) vs spawned vs stopped); click it to open the panel.

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

  1. Installation
  2. Quick start
  3. Surfaces
    • In-editor chat
    • Chat participant (@dsh)
    • Simple Browser (dsh web)
    • Sessions sidebar
    • Status bar
  4. Slash commands (unified)
  5. All commands (palette + menus)
  6. Configuration (dsh.*)
  7. Wire protocol
  8. Capability gate
  9. Transports
  10. Reuse-or-launch
  11. Decoupling from upstream churn
  12. Project layout
  13. Verifying a fresh dsh release
  14. Troubleshooting & debug flags
  15. Known limitations

Installation

cd /path/to/dsh-vscode
pnpm install            # or npm install
pnpm run build          # bundles src/extension.ts → out/extension.js
                        # and src/chat/inEditorChat.webview/ → out/chat/inEditorChat.webview/main.js
pnpm run test           # runs vitest unit tests
pnpm run package        # produces dsh-vscode-companion-0.1.0.vsix
code --install-extension dsh-vscode-companion-0.1.0.vsix

pnpm run build is mandatory before pnpm run package — vsce package skips the prepackage hook in some Node versions, so the shipped .vsix would otherwise contain stale out/ artifacts. Always run pnpm run build (or pnpm run package, which runs the same pipeline) before pnpm run package.

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

  1. Install the .vsix (see above).
  2. On first activation the extension probes 127.0.0.1:3080 for a running dsh web (TCP + /manifest.webmanifest whose name must equal "DeepSeek Harness"). If found it is adopted and the status bar reads running :3080 (reused). If not, the extension spawns npx --yes @deepseek-ai/dsh web (overridable via dsh.executable + dsh.executableArgs) and the status bar reads running :3080 (spawned).
  3. Set your DeepSeek API key: command palette → DeepSeek: Set DEEPSEEK_API Key. The key is stored in VSCode SecretStorage under dsh.deepseekApiKey and forwarded to dsh as DEEPSEEK_API_KEY.
  4. Open the in-editor chat: editor title bar icon, or dsh.openInEditorChat. The first prompt auto-creates a session bound to the workspace cwd.
  5. Optional but recommended: leave dsh.chatParticipant: true (the default) so @dsh shows up in VSCode's chat panel as well.

To opt out of the auto-start, set dsh.autoStart: false. The probe always runs first, so an already-running dsh in another window is adopted, never forked.


Surfaces

In-editor chat

A WebviewPanel rendered in the active editor column with a self-contained Preact chat UI. Open it from the editor title bar icon, the palette (dsh.openInEditorChat), the sidebar Sessions tree (left-click on a row → dsh.focusEditorChat), or after running dsh.sendSelection / dsh.addPath.

Why a custom webview (not Simple Browser) for chat

Simple Browser is the right surface for the full dsh web UI — dsh's loopback server renders correctly there because Simple Browser honours Origin: 127.0.0.1:N natively. A custom webview runs under vscode-webview://... and dsh's /api browser-trust fence rejects that origin (Origin !== Host). So the in-editor chat is a separate Preact app that talks to the extension host via postMessage; the host talks to dsh via HttpApiClient over the same loopback the Simple Browser surface uses. The two surfaces are isolated on purpose — chat vs deep-control — and the in-editor chat gets a tightly-scoped CSP (default-src 'none', script-src 'self' ${cspSource}) that Simple Browser cannot enforce.

Layout

┌──────────────────────────────────────────────────────────────┐
│  dsh chat · <session title>        [Chat] [Trajectory] [New] │  ← Header
├──────────────────────────────────────────────────────────────┤
│                                                              │
│   [user message]                                             │
│   ┌─ Assistant run ──────────────────────────────────────┐   │
│   │ ▷ Thinking… (collapsible)                           │   │  ← AssistantRun
│   │                                                      │   │
│   │ Final answer text…                                   │   │
│   │ **read**  src/extension.ts                           │   │  ← tool card
│   │ *opened in editor*                                   │   │
│   └──────────────────────────────────────────────────────┘   │
│                                                              │  ← Messages
│   ┌─ Loading history… ──────────┐                          │
│   │  • • •                       │  (placeholder on first bind) │
│   └──────────────────────────────┘                          │
│                                                              │
├──────────────────────────────────────────────────────────────┤
│  [🔒 Workspace Write] [⚡ Standard] [🤖 GPT-4] [⚙ Medium]    │  ← Chips
│  [💬 Plan mode]                                              │
├──────────────────────────────────────────────────────────────┤
│  📎 3 attachments attached              [+] [✕ clear all]   │  ← Attachments
├──────────────────────────────────────────────────────────────┤
│  /  Type a message…                                          │  ← Composer
│  ──────────────────────────────────────────────────────────── │
│                                                   [Send ▶]   │
└──────────────────────────────────────────────────────────────┘

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 session.prompt against the currently bound session; the host opens a dsh /api/events.mux WebSocket and forwards every assistant/chunk, tool/call, tool/result, user/message, turn/start, turn/end, and session/projection (plan-mode) frame into the webview's reducer. The webview renders them via a single foldSessionEvent primitive.

Clicking Stop triggers the closed-loop cancel protocol (the v2 fix that replaced the earlier "fake stop" + lost trailing events):

  1. Target the session actually running the in-flight turn via state.activeSessionId, not state.sessionId. The two can drift — /new mid-turn rebinds the webview to a brand-new session while the old turn keeps running on the old one. A cancel that targets the bound session would land on an empty session and the real turn would keep running.
  2. POST /api/session.cancel and wait for { accepted: true }. The ack is "I received your cancel, will cooperatively abort", not "abort completed". dsh still has work to drain (in-flight tool, final chunks, turn/end { reason: 'aborted' }, then session.list.running=false).
  3. On success: do not abort the local stream reader. Letting it live means it picks up the trailing assistant chunks and the turn/end event the webview renders as a "turn interrupted" info bubble. The existing 400 ms session.list poll already detects running=false, the race resolves, runTurn's finally blocks run, and { type: 'done' } is posted. Composer comes back at the same moment the webview has rendered the interruption. This is a true closed loop.
  4. On failure (dsh unreachable, RPC error, agent-busy for session-backed subagents): surface an info bubble so the user knows dsh didn't acknowledge, and abort the local reader so the composer comes back. dsh may keep generating on its own; nothing more the host can do without a working RPC.

A cancelInFlight flag guards against double-POSTing when the user mashes the Stop button while the first cancel is still mid-round trip. The first click owns the POST; subsequent clicks just abort local for immediate UI release.

The Chat Participant (@dsh) ships the same closed-loop semantics on top of VSCode's built-in Stop button, with the same target-the- active-session distinction (vscode.CancellationToken → session.cancel).

Attachments ("Add to context")

Click the + next to the attachment strip (or call dsh.sendSelection / dsh.addPath) to open a QuickPick:

Pick Then
Add files showOpenDialog — picks one or more workspace files
Add a note showInputBox — free-form text (paste an error, a snippet, a question)
Add both file picker → note input (sequential, each cancellable)

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 @file: <relative-path> lines on send; notes become <note>...</note> fenced blocks. The webview renders the attachments as chips above the textarea with ✕ to remove individually and clear all to empty the strip. Pickers respect MENTION_FILE_EXCLUDE (**/node_modules/**, **/.git/**, **/dist/**, **/out/**, **/.next/**, **/.cache/**, **/.DS_Store).

Mention menus (@file)

Typing @ in the composer opens a workspace-file mention popup (filter-as-you-type against vscode.workspace.findFiles('**/*', MENTION_FILE_EXCLUDE, 1024)). Tab / Enter inserts the highlighted path. The popup supports nested directories — clicking a folder expands it in-place and walks its contents. The first @ opens the root; subsequent @s reuse the cached file list so the cost is one findFiles per session.

Folders in the mention menu can be expanded/collapsed; the expansion state lives in mentionExpandedDirs so it survives across composer re-renders.

Slash menu (the unified popup)

Typing / opens a popup of every slash command this host knows about — see Slash commands (unified) for the full table. The popup merges four sources, in priority order:

  1. DSH-side commands (kind: 'slash') — whatever dsh currently reports via commands.list. Capability-gated via caps.methodAvailable(RpcMethod.CommandsList); older dsh builds that don't ship commands.list simply contribute nothing here.
  2. Agent presets (kind: 'preset') — agentPreset.list's roster, gated on caps.hasAgentPresets. Picking a preset opens the same picker as the chip click.
  3. Skills (kind: 'skill') — skill.list's roster, gated on caps.hasSkillList. Skills invoke dsh's skill execution path.
  4. Extension-owned commands (kind: 'local') — every id in SLASH_DISPATCH (new, compact, clear, panel, refresh, switch) plus popup-only /pick. These are extension-local actions that dsh has no wire for.

Host rows win collisions on the same id (e.g. when dsh ships its own /new, the host's row is what the popup shows). The popup is flat-alphabetical — no grouping — per user feedback.

DSH-side text slash commands (/goal, /plan, /feedback) also support a slash-context hint: clicking the popup row renders /<id> as real text in the textarea plus a gray ghost-text placeholder (请输入目标,智能体将持续运行 for /goal, etc.). The hint is visual only — the placeholder never enters the prompt payload; on submit the typed text is dispatched as args via postToHost({type:'slash', id, args}).

Chips

Five chips sit between the messages area and the attachments strip. Each is a thin wrapper over dsh's RPC surface; the chip click forwards to the host, which forwards to dsh, which echoes the authoritative state back via *_mode_changed envelopes. The chip never echoes the user's optimistic pick — it always reconciles to whatever the runtime materialised.

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:

  1. Aborts the in-flight turn (if any).
  2. Re-binds the ThreadSessionResolver to the new id under the workspace's cwdKey(cwd) (normalised — Windows backslashes + case + trailing slash all collapsed, so the sidebar and the picker land on the same binding).
  3. Fetches session.history (last 50 events) and posts them as sessionHistory BEFORE sessionInfo, so the chat shows the "Loading history…" placeholder for one frame and then fills cleanly (the reverse order would flicker for one frame).
  4. 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.
  5. 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.mux WebSocket 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 for blocks[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 the STREAM_MAX_EVENTS cap. Between these boundaries a 120 ms setInterval flushes the trailing slot so the user sees real-time updates even when the model keeps emitting text-delta without a block-end arriving. 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>** \`` for tool/call and italic *<preview>* / *error: <code>* for tool/result. File-targeting tools (read / write / edit / view / create_file / str_replace / multi_edit / cat / notebook_edit) also emit a response.reference(uri) chip that opens the file in VSCode on click — matching Claude Code's VSCode extension UX. Tool-call deltas on assistant/chunk are skipped in favour of the assembled tool/call event.
  • Three-layer dedup for the 3× same-content emit that dsh does for streaming providers (text-delta chunks + block-end-text assembled block + assistant/message assembled message). Per-index flushedText / flushedReasoning Sets drop a late block-end for a slot already emitted as its own part. The buffered-text check on the assistant/message branch (currentBlock?.kind === 'text') drops the message when streaming text is still buffered. The sawAnyText flag gates any further assistant/message for the rest of the turn. turn/end resets 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 BEHAVIORS registry the in-editor chat uses, so /new / /permission / /agent / /model / /effort produce the same UX shape (QuickPick picker in chat, chip picker in editor) and the trailing-text fall-through (/new Hello does 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 via dsh.chatExcludeReferenceIdPatterns (default prefixes: vscode.instructions., vscode.customizations., vscode.implicit., Browser — the last one matches the chrome-devtools MCP tool's Browser Pages id). User-attached references (file, symbol, …) use short semantic ids that don't collide and pass through unchanged. Set dsh.chatIncludeReferences: false to skip the whole block. Set dsh.chatDebugReferences: true to dump every reference id and the FULL final prompt into the OutputChannel — useful for discovering new prefixes to add. The handler also lifts sessionId into result.value for question / approval answers (without it, dsh returns not-pending from the schema validator).
  • Disabled via dsh.chatParticipant: false. The participant id is dsh by default (overridable via dsh.chatId).
  • Transport is dsh.chatStream: websocket (default, recommended) or poll (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) plus cwd (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 @dsh chat participant is currently bound to this session (driven by onInEditorSessionChanged → setCurrentSessionId)
    • ⬜ idle (outline) — neither running nor current

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).

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_DISPATCH keys) — 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 via commands.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 via commands/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 — /clear and /init are deliberately surfaced as Unknown command /<id> in the chat participant. dsh has no /clear or /init of its own, and the old handlers were misleading no-ops (clearing the chat-thread → session binding did nothing because session.create is idempotent on sessionId + cwd, so the next prompt re-resolved the same active session). The slash rows remain registered in package.json > contributes.chatParticipants[].commands[] so the protocol surface is stable. The /clear slash still works in the in-editor chat — there it does reset the local view (session history kept on dsh). Re-enable when dsh ships /clear or /init and 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):

  1. HttpApiClient (loopback HTTP, always on). POSTs to http://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 @dsh chat 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.

  2. JsonRpcGateway (stdio JSON-RPC bridge, opt-in). Spawns dsh-jsonrpc-agent as a child process and exchanges newline-delimited JSON frames over stdin/stdout. Only a subset of methods are stdio-routable today (STDIO_METHODS in gateway.ts — currently just host.describe and session.prompt); everything else goes through HTTP even when the bridge is enabled. The bridge auto-enables when dsh.jsonrpcAgentBin resolves and dsh.jsonrpcConfigPath is empty or points at an existing file; set dsh.jsonrpcBridge explicitly 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:

  1. CLI argv — dsh web [--host H --port N --trusted-host A]
  2. stdout readiness line — ^dsh web: http://127.0.0.1:\d+
  3. Loopback HTTP — /manifest.webmanifest, /, /api/*
  4. 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:

  1. Wire port (src/sdk/contracts.ts + gateway.ts). Every RPC method literal lives in RpcMethod.*; every per-method request / response shape lives next to it. The DshGateway interface declares each method as a typed async function. Both transports (HttpApiClient for loopback HTTP, JsonRpcGateway for 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.

  2. Version gate (DshCapabilities, built from host.describe.version). Anything that didn't exist on older dsh (skill.list, subagent.*, settings.describe, …) is checked via caps.methodAvailable(RpcMethod.X) before the UI calls the port. The METHOD_SINCE table in src/sdk/gateway.ts is the single place to add a new capability row when dsh ships one. See Capability gate.

  3. Settings indirection (src/config/ConfigKeys.ts + SecretStore). The dsh-side canonical secret key ('secrets.deepseekApiKey') resolves through a memoising createDshKeyResolver() that merges the static DSH_KEY_TO_VSCODE fallback with whatever settings.describe reports on the live host. The static map is always authoritative for keys it covers — the user's saved secret at dsh.deepseekApiKey never gets silently renamed behind their back, even if dsh ships a hostile overlay entry. The static map is consulted synchronously (so DshServer.spawnFresh can read DEEPSEEK_API_KEY BEFORE 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-pkg has no Windows artifact. On Windows the JSON-RPC bridge uses node <repo>/packages/examples/jsonrpc-demo/lib/bin.js from a local dsh checkout. The main web UI works on Windows because npx @deepseek-ai/dsh web is portable.
  • vsce package only skips the prepackage hook when invoked directly via npx vsce package (which bypasses the package manager's lifecycle). Use pnpm run package instead -- pnpm's lifecycle runs prepackage (pnpm run build) for you, so the shipped .vsix never contains stale out/ 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 latest of @deepseek-ai/dsh-sdk-jsonrpc-demo is stale (0.0.1-rc.1). Pin an explicit version (the working release is on the @next tag) or use a local checkout.
  • sendSelection / addPath always route through HTTP session.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 @dsh for streaming.
  • /clear and /init are deliberately surfaced as Unknown command /<id> in the chat participant (dsh has no equivalent). They remain registered so the protocol surface is stable. The in-editor chat's /clear still clears the local view (session history kept on dsh).
  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
© 2026 Microsoft