DSH AgentChat with the DeepSeek Harness
(
Open source (MIT): the source lives at github.com/taokeqin/dsh-vscode-acp. Please report bugs and request features in the issue tracker. Sessions are ordinary editor tabs, the transcript renders as Markdown, and the agent runs as a child process over stdio — no local web server and no port to secure. Requirements
The extension finds Getting started
The whale in the editor title bar (DSH: Open) reopens your most recent conversation from any file. Features
Commands
Settings
Known limits
The rest of this document is engineering detail: what was measured, what surprised us, and why the design is what it is. Why ACP and not the web GUI
ACP has no auth ( What you get, and what you don'tACP is scoped by DeepSeek to automation. Its README says to avoid it "when a human needs DSH-specific presentation cards, plans, titles, todos, terminal views, or elicitation". That boundary is real and permanent, not a gap to be filled later.
Transcript replay is recovered separately — see below. Opening a sessionThe whale in the editor title bar is DSH: Open — the same placement Claude Code
uses for its own ( That qualifier matters. A session is created before its first message, so an
untouched one — from a Title-bar icons are rendered as images rather than masked like container icons, so
Where the session actions liveInside the panel, in a header row: the session title, + New, and History — which drops a session list under the button, in the panel. Switching conversation should not send you to another part of the window, so it does not focus the sidebar view. Rows mark which sessions have a tab open and which one you are looking at; picking the current one does nothing rather than pointlessly resuming it. The sidebar list and this one are built by the same Model and reasoning effort sit in the composer, next to the context ring; Stop appears there while a turn runs. The ring is left-aligned with them — against the right edge it read as if it belonged to the Send button. They started in the editor title bar, following Claude Code, and that was the wrong
read of what Claude Code does. Its Two failures made the reason concrete:
Adding Transcript replay (best effort,
|
| Source | Why |
|---|---|
<dshHome>/sessions/<slug>/ |
works with no agent running |
session/list when the agent is up |
authoritative for resumability (scoped by cwd) |
| sessions with an open tab | absent from session/list, which returns only inactive sessions |
session/list is called with a cwd and the result is filtered again
client-side. Without that argument the agent returns sessions for every workspace it
has ever served — 27 across 11 directories on this machine — which would fill the
sidebar with other projects' conversations.
Delegated sub-agent runs are filtered out by delegationDepth > 0 — the disk holds
them alongside real conversations (3 of 28 here), and only depth 0 is something the
user started. This is what session/list means by "root sessions".
Closing a tab closes the session agent-side, which is required rather than tidy: an
active session never appears in session/list, so one left open could never be
reopened.
Entries are labelled with the session title (dsh writes the first user message as a
fallback title, since the acp profile disables model-generated ones) and a relative
timestamp, read from the on-disk log. Metadata uses a 64-frame budget rather than
decompressing the whole log — 2 ms versus ~600 ms per session on the largest one
here — and is cached per window. A session whose metadata cannot be read still
appears, labelled (no messages yet).
A title only exists after the first turn completes, since that is when dsh writes it; the list refreshes at that point.
What the log actually looks like
Measured, because none of this is documented:
- Path:
~/.dsh/sessions/<slug>/<sessionId>/session[.v<N>].jsonl.zstd, where<slug>is the cwd's path segments joined by-and wrapped in--. The name is versioned — older sessions use the unversionedsession.jsonl.zstd, current dsh writessession.v3.jsonl.zstd— and a resumed session can carry both, with the unversioned file frozen at the switch. We read the highest version present (findLogFile) and locate a session by scanning for its id rather than trusting the slug rule. - Records carry
type,seq,time,data. Exactly three types are marked withsurfaceOp—user/message,assistant/message,tool/result— and those are the visible transcript. The other 29 types are internal bookkeeping. user/messagedoes not mean the person typed it. dsh splices scaffolding into the conversation under the same record type, discriminated bydata.source.kind. Across 55 sessions here:user(66),plugin(43),skill-catalog(36),goal(25),agent-message(5),subagent-settled(4). Onlyuseris real input; the rest rendered as walls of runtime context and skill catalogs the user never wrote, so onlyuseris kept. A record with nosource.kindis kept too — that is a shape we do not recognise, and hiding a real message is the worse error.tool/callhas nosurfaceOpbut supplies the tool label, paired bycallId.- The log also contains
todo/write,goal/change, andapproval/*records — surfaces ACP deliberately withholds. Not rendered today; available if wanted.
The multi-frame trap
dsh appends one zstd frame per write, so a real log is a concatenated
multi-frame stream — 9014 frames in the largest session here. Node's zstd
bindings decode only the first frame and stop: both zstdDecompressSync and
createZstdDecompress returned 202 bytes of a 6.3 MB log, which silently looks
like an empty transcript rather than an error.
The decoder therefore walks frames itself, advancing by the stream's bytesWritten
after each one. Measured 549 ms for the 3.2 MB / 9014-frame worst case (the zstd
CLI does it in 63 ms and is kept as a fallback for hosts whose Node predates zstd,
added in 22.15). Output is byte-identical to the CLI.
Finding the dsh executable
spawn('dsh') is not enough. A VS Code launched from the Dock inherits the system
PATH — /usr/gnu/bin:/usr/local/bin:/bin:/usr/bin here — not your shell's, so an
install under a version manager is invisible to it and the agent fails with
spawn dsh ENOENT even though which dsh works in every terminal.
Discovery runs cheapest-first and only the last step spawns anything:
dshAgent.executablePathwhen set — an absolute path is taken as-isprocess.env.PATH— works when VS Code was started from a terminal- version-manager and package-manager directories: every installed nvm version,
then volta, fnm, asdf, bun, homebrew,
~/.npm-global - the login shell's own PATH (
$SHELL -lic 'command -v dsh'), with a 5 s timeout
The Windows branches differ throughout and are simulated in tests, not verified on
a Windows host: the PATH separator is ;, an npm CLI is a .cmd shim so a bare
name finds nothing, the candidate directories live under APPDATA/LOCALAPPDATA,
and there is no login-shell step. Path joining picks path.win32 or path.posix
from the target platform rather than following the host, which is what lets those
branches be exercised from macOS at all.
If all four fail, the error names dshAgent.executablePath and offers a button that
opens that setting.
Finding the file is only half of it. dsh is a Node CLI whose shebang is
#!/usr/bin/env node, so it resolves node through its own PATH at exec time.
Handing the child the inherited system PATH reproduces the same failure one level
down — env: node: No such file or directory, exit 127 — because the version
manager's node is no more visible to the child than dsh was to us. The child
therefore gets the directory dsh was found in prepended to PATH (for nvm, fnm, volta
and homebrew node sits right next to dsh), falling back to a search of the
well-known directories for a runtime.
test/gui-launch.mjs rebuilds the Dock-launch PATH and drives a real handshake
through it, which is the only way to catch that second failure: locating a file says
nothing about being able to exec it.
Security notes
The agent writes files with no prompt. Measured: asking the agent to create a
file produced the file, and session/request_permission never fired — the shipped
acp profile auto-approves tool use. This extension implements the permission
prompt (the policy layer is patchable), but do not treat it as a safety net.
The agent has the same filesystem reach as running dsh in a terminal.
Other deliberate choices:
dshAgent.executablePathanddshAgent.profilearescope: machine, so a repository's.vscode/settings.jsoncannot redirect the spawned binary.untrustedWorkspaces.supported: false— the extension stays off until you trust the folder.- The child is spawned with an argv array and
shell: false. - File paths in tool rows are confined to the workspace before opening.
- Webview CSP uses a
crypto.randomBytesnonce; all agent text reaches the DOM throughtextContent, neverinnerHTML. - Zero runtime dependencies.
Measured agent behaviour
Facts this client depends on, each verified against dsh 0.1.2-rc.1
(deepseek-harness-acp/0.0.1) rather than taken from docs:
session/listreturns only inactive sessions. The active one is absent and reappears aftersession/close— so switching sessions must close the old one, or it becomes unreachable.session/resumerestores context but replays no history; the panel starts empty. Transcripts live in~/.dsh/sessions/<cwd-slug>/<sessionId>.promptCapabilities.imagetracks the configured model route, not the protocol:deepseek-v4-flashreportsfalse,deepseek-v4-flash-vision-expreportstrue.- Tool output nests one level:
content[].content.text. - A turn emits
tool_call→tool_call_update→agent_thought_chunk→agent_message_chunk, withusage_updateinterleaved.
Develop
npm install
npm run compile # tsc
npm run test:unit # every headless suite: no agent, no credentials
npm test # …plus a real ACP handshake and smoke turns (needs dsh)
npm run package # dsh-agent.vsix
src/acp/ imports no vscode, so the protocol layer runs headless under npm test.
Agent-facing notes — where things live, the invariants, and the package/install/release
commands — live in AGENTS.md.
Releasing
Releases are published by GitHub Actions — no local publish step. The workflow
.github/workflows/publish.yml runs on a v* tag push: it checks the tag matches
the version in package.json, packages the .vsix, publishes it to the Visual
Studio Marketplace (and to Open VSX when its token is configured), then attaches
the .vsix to a GitHub Release.
Bump
versioninpackage.jsonand add a matching entry toCHANGELOG.md.Commit and push, then tag and push — the tag must equal the package version:
git tag v0.2.1 git push origin v0.2.1Watch the run under the repository's Actions tab.
The run fails loudly if the tag and version disagree or if the Marketplace token is missing, so mistakes surface in CI rather than as half-published releases.
Secrets, configured in Settings → Secrets and variables → Actions. They must be
reachable by the publish job, which declares environment: prod — so put them
either in the Secrets tab (repository-level, visible to every workflow) or as
environment secrets on the prod environment (scoped, but only to jobs that
declare it):
| Secret | Required | Purpose |
|---|---|---|
VSCE_PAT |
yes | Marketplace token for publisher hacken — create at marketplace.visualstudio.com/manage, scope Marketplace → Manage |
OVSX_PAT |
no | Also publish to Open VSX (the hacken namespace must exist there) |
Status
Working slice: chat, streaming, tool rows, session resume/switch, transcript replay, model picker, cancel, send-selection, context-chips (files and selections).
Not done: images, MCP server mounts, prompt queueing, rendering todos/plans from the on-disk log.