Skip to content

feat(acp): ship an ACP bridge for DeepSeek Harness - #2291

Merged
wgqqqqq merged 11 commits into
GCWing:mainfrom
wgqqqqq:feat/dsh-acp-bridge
Aug 14, 2026
Merged

feat(acp): ship an ACP bridge for DeepSeek Harness#2291
wgqqqqq merged 11 commits into
GCWing:mainfrom
wgqqqqq:feat/dsh-acp-bridge

Conversation

@wgqqqqq

@wgqqqqq wgqqqqq commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

What this does

Makes DeepSeek Harness work in BitFun the way codex / claude-code / opencode already do: a user installs dsh, picks a model inside it, and starts a session. BitFun stores no DeepSeek credentials and configures no DeepSeek models.

The harness ships no ACP entry point of its own. The published @deepseek-ai/dsh-acp is an automation surface — it deliberately withholds tool calls, reasoning, and mode selection, because a script does not need to watch an agent think. An IDE needs exactly those. So this adds packages/dsh-acp, an IDE-facing ACP server, and ships it as a dsh profile rather than as a package the user has to install separately.

Why a profile

A dsh profile is just a directory under $DSH_HOME/profiles/. On every launch the harness symlinks its whole dependency closure into $DSH_HOME/profiles/node_modules, so a profile resolves every @deepseek-ai/dsh-* plugin out of the user's own installation through ordinary Node parent lookup. BitFun therefore ships ~200KB of compiled bridge, not a second copy of the harness, and launches it as dsh --profile bitfun-acp.

The desktop bundle carries packages/dsh-acp/dist-profile as a Tauri resource; client/dsh_profile.rs materializes it into the user's dsh on first use, comparing a content stamp so it re-syncs only when the build changes.

Where the model and the key come from

cordis.yml mounts the harness's own dsh-settings-file, dsh-credentials-local, and dsh-agent-default-model rows. The model is whatever the user selected in dsh; the key is resolved per request from $DSH_HOME/.credentials.yaml (what dsh's Models page writes) with the usual .env and environment fallbacks. Nothing about an account is stored in this repository, and the bridge never asks for a key of its own.

Relationship to #2272

This replaces the dsh preset added in #2272, keeping the id dsh. That preset launches @deepseek-ai/dsh-acp-demo, which is the automation surface described above: no tool cards, no reasoning, no mode picker, and a hand-written --config path. The two cannot coexist under one id, and offering the user two DeepSeek entries that differ in ways they cannot see would be worse than picking one.

Also in this PR

Two changes that are not dsh-specific — they were needed to make a dsh session look right, and they apply to every ACP agent:

  • Native tool cards (tool_card_bridge/, stream.rs, +534): ACP tool calls render as BitFun's own Bash / Read / Edit cards instead of generic blobs, with results and reasoning grouped by round.
  • ACP mode picker (ModelSelector.tsx, +423): an agent may publish models, a mode config option, or both. The picker now handles all three, and collapses to a single disabled entry with a reason once a conversation has started and its mode is fixed.

Happy to split these out if you would rather review them separately.

Closing a session and opening it again

A reopened ACP session was blank, and its mode picker had unlocked and reverted to the default. Three separate causes, all fixed here; the first two are not dsh-specific either.

The turns never reached disk. The projection is the only writer of an ACP turn, but it had no storage slot to write into — that slot arrives with the backend's DialogTurnStarted, and nothing in the local runtime starts a turn an external agent runs, so every save was deferred forever. The projection now allocates the slot itself, and declines to guess when guessing would overwrite history.

The backend refused the save it did get. save_persisted_dialog_turn validates a turn against the runtime's history branch for that session, and an externally driven session has none, so a first turn failed with OutcomeUnknown. It now persists straight through for a session whose provider metadata says an external agent owns it; a runtime-owned session still needs its branch, pinned from both sides by a new test. The desktop path also stopped loading such a session into the session manager first, which restored nothing and rewrote its persisted mode to a local fallback.

The bridge never advertised loadSession, so BitFun had only session/new to fall back on — every stored session in my install carries acpResumeStrategy: "new" for that reason. packages/dsh-acp now implements session/load: a cold session resumes out of the harness's own persistence and replays its history, the mode comes back from the session's own log rather than the roster (so a conversation reopens under the preset it was written under, however the default has moved) and is locked once the conversation has started, and a load is refused for a session that was never stored or that belongs to another directory.

Build

packages/dsh-acp is deliberately not a pnpm workspace member. It pins the whole harness 0.1.0-rc.6 train and carries its own package-lock.json, so installing the harness is the desktop bundle build's cost rather than every contributor's pnpm install. scripts/prepare-dsh-profile.mjs runs npm ci + tsc + the profile packaging as part of frontend:build-all, and fails the build on error — an app that silently ships no bridge is indistinguishable from a working one until a user starts a session. BITFUN_SKIP_DSH_PROFILE=1 opts out on purpose.

One note for anyone touching the pins: half of these packages still carry an npm latest of 0.0.1-rc.1 while next points at 0.1.0-rc.6. An unpinned install mixes the two trains and disagrees with itself; that is why every version here is exact.

Verification

  • Clean-room build: npm citsc → profile packaging in an empty tree, from the registry alone, no local harness checkout.
  • The built profile materialized under a real dsh install: handshake, session/new, three modes offered, then a full turn with streamed reasoning, a bash tool call, its result, and end_turn — with the key only in $DSH_HOME/.credentials.yaml.
  • The same profile driven through session/load against that install: loadSession advertised, a stored conversation replayed as user message → reasoning → tool cards → assistant text, and one locked mode returned.
  • packages/dsh-acp: 30 vitest tests, tsc --noEmit clean. Fixed one flaky test that waited on the event count rather than on the notification it was asserting about (~1 failure in 6 full-suite runs).
  • cargo test -p bitfun-acp (136), the new product_runtime persistence test, cargo check -p bitfun-desktop, rustfmt on the touched files and cargo clippy on the touched crates (no new warnings); web-ui vitest suite; tsc --noEmit.

Remote workspaces

A remote / SSH workspace materializes the same profile on the remote host: one probe round trip reads $DSH_HOME, the harness version, and the installed stamp there, and when the stamp does not match this build the profile goes over as a single tar stream on the session's own transport — not SFTP, so container connections are covered too. The remote host supplies dsh, the model, and the key, exactly as a local one does; if dsh is missing or too old there, the launch fails with that sentence instead of with a missing-profile error from the launcher.

The harness has no ACP entry point of its own, and the published
@deepseek-ai/dsh-acp is an automation surface: it withholds tool calls,
reasoning, and mode selection, because a script does not need to watch an
agent think. An IDE needs exactly those.

So add packages/dsh-acp and ship it as a dsh PROFILE rather than as
something the user installs separately. dsh symlinks its whole dependency
closure into $DSH_HOME/profiles/node_modules on every launch, so a profile
resolves every harness plugin out of the user's own installation. BitFun
therefore carries a compiled bridge, not a second copy of the harness, and
launches `dsh --profile bitfun-acp`.

The model and the key stay in dsh. cordis.yml mounts the harness's own
settings, credentials, and default-model rows, so whatever the user picked
in dsh's Models page is what an IDE session runs on. BitFun stores nothing
about the account.

This replaces the `dsh` preset from GCWing#2272, which launched the automation
surface under the same id.

Two of the changes here are not dsh-specific and apply to every ACP agent:
tool calls now render as BitFun's native Bash/Read/Edit cards grouped by
round, and the model picker handles an agent that publishes a `mode`
option instead of models, collapsing to a locked entry once a conversation
has fixed its mode.

packages/dsh-acp is deliberately not a pnpm workspace member: it pins the
whole harness 0.1.0-rc.6 train and carries its own package-lock.json, so
installing the harness is the desktop bundle build's cost rather than
every contributor's. prepare-dsh-profile.mjs fails the build rather than
shipping a bridgeless app, since the two are indistinguishable until a
user starts a session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wgqqqqq wgqqqqq changed the title feat(acp): ship an IDE-facing ACP bridge for DeepSeek Harness feat(acp): ship an ACP bridge for DeepSeek Harness Aug 14, 2026
wgqqqqq and others added 8 commits August 14, 2026 20:59
The Rust job never runs `prepare:dsh-profile`, so `packages/dsh-acp/dist-profile`
— a bitfun-desktop Tauri resource — was absent and the build script failed with
"resource path doesn't exist" on all three platforms. Create it the same way CI
already creates `src/mobile-web/dist`, and teach the preflight check and
CONTRIBUTING about it so the same failure is self-explaining locally. The
core-boundary check needs the client feature's new optional dependencies in its
reviewed list, which is the other half of the red.

Then the limitation the PR disclosed: profile materialization ran only in
`start_local_transport`, so a remote workspace launched `dsh --profile
bitfun-acp` against a host that had no such profile. It now runs for remote
transports too — one probe round trip for `$DSH_HOME`, the harness version, and
the installed stamp, then the built profile as a single tar stream over the
session's own transport when that stamp is stale. Streaming rather than SFTP is
what makes this work for container connections, and the stamp travels last so an
interrupted upload reads as stale rather than as current.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ile deps

The reviewed feature list lives in two places — the rule and the self-test's
mirror of it — and only the rule was updated, so the check passed while the
suite that guards it failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A remote ACP client that exits during startup left nothing in the log but
a broken pipe from the first `initialize`, because the launch path copied
its stderr into a sink. Log those lines instead: they are the only place
the reason ever appears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Materialization decided "installed" from `dsh --version` on stdout, while
the agent list decides it from `command -v`. A host where the launcher
resolves but answers nothing on stdout therefore showed as available and
then refused to launch. Resolve presence with `command -v` and keep the
version as best effort on either stream, so an unparsable answer falls
through the version gate rather than blocking a working host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`WorkspaceStdio` documents that the three IO streams are what lease the
process, and that `control` and `completion` do not keep it alive by
themselves. Both channel loops contradicted that: `control_rx.recv()`
returning `None` — which is just the last `WorkspaceProcessControl`
sender going out of scope — was matched together with `Kill`, so a
caller that drives the process purely over stdio killed it the moment it
dropped the handle it had no use for.

Remote ACP is exactly that caller. The agent was SIGKILLed microseconds
after exec, and the only trace left was a broken pipe on the first
`initialize` — no stderr, because it never got to write any.

Resolve the arm only on real signals, and cover both loops with a test
that dropping the handle lets the process run to its own exit status.
An agent that dies mid-session leaves behind the same broken pipe as one
that never started. The exit code says which.
A client that exits before answering `initialize` produced one sentence —
"exited before initialization completed" — which tells the user only what
they already know. Its stderr held the whole explanation and went to a
log file, or, for a local agent, to a terminal a packaged app does not
have.

Read both transports' stderr into the log line by line, keep the tail,
and quote it in the error the user is shown. Waiting for EOF first is
what makes the quote complete: the pipes closing is the signal that the
agent is gone, and its last lines are still in flight then.

Also ask the remote host for its Node version in the probe round trip we
already make, and refuse the launch when it is below 20.12 — the release
that added `util.parseEnv`, which the harness imports on its first line.
dsh declares no `engines`, so npm installs it onto Node 18 without a word
and the failure surfaces from deep inside the launcher.
DeepSeek Harness's PTC preset answers a whole step with one `run_code`
call whose argument is a TypeScript program. The bridge classified it by
kind alone, so it landed on the terminal card, which reads `command` —
a field the call does not have — and drew an empty card.

Give it its own identity and card: `run_code` (and anything shaped like
it: Execute kind, a `code` argument, no `command`) becomes `RunCode`,
rendered as the program plus what it printed. A shell call that happens
to carry a `code` argument is still Bash.

While reading the result path: ACP results carry their text in content
blocks, but the terminal card reads `output`/`stdout`, so every ACP Bash
card showed the command with nothing underneath it. Lift that text into
`output` for both cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wgqqqqq
wgqqqqq force-pushed the feat/dsh-acp-bridge branch from a690ff7 to 2afb8c0 Compare August 14, 2026 16:37
wgqqqqq and others added 2 commits August 15, 2026 00:49
An ACP session came back empty after a restart. Its turns were never
written, and three layers each had a reason.

The projection is the only writer of these turns, but it had no storage
slot to write into. That slot arrives with the backend's
`DialogTurnStarted`, and no such event exists for a turn an external
agent runs — nothing in the local runtime starts it. Every save was
therefore deferred, forever. Allocate the slot in the projection instead,
from the turns and catalog it already holds, and decline to guess when
the session has persisted turns none of which are projected yet, where a
guess would overwrite history.

The backend then refused the save it did receive. `save_persisted_dialog_turn`
validates a turn against the runtime's history branch for that session,
and an externally driven session has none — the runtime neither starts
nor completes those turns — so a first turn failed with OutcomeUnknown.
Read the session's `provider` metadata and, when an external agent owns
it, persist straight through. A runtime-owned session still needs its
branch, which the new test pins from both sides.

Finally, the desktop path loaded every session into the session manager
before saving, which for an ACP session restored nothing and rewrote its
persisted mode to a local fallback. Skip the load for the same reason:
there is no runtime state to restore.

The `provider` key and its `acp` value move into core-types, so the ACP
client that writes them and the runtime that reads them back share one
definition rather than two string literals that have to agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closing a dsh session and clicking it again gave a blank one: the bridge
never advertised ACP's `loadSession`, so BitFun had only `session/new` to
fall back on. Every one of a user's stored sessions carries
`acpResumeStrategy: "new"` for that reason. The reopened conversation
lost its history and its context, and its mode picker unlocked and
reverted to the roster default — a session that has already spoken must
not be able to change the composition its transcript was written under.

Implement `session/load`. A live session replays from memory; a cold one
resumes out of the harness's own persistence, which is the case that
matters, since a client restart is exactly when nothing is in memory. The
archive is read through `inspect`, not a listing: a session disposed a
moment ago is still draining, and `list` does not wait for it while
`inspect` does — reopening the session you just closed is the first thing
a user does.

The mode comes back from the session's own log rather than the roster, so
a conversation started under a preset reopens under it however the
default has moved, and `presetOptions` locks the picker for a session
whose conversation has started. A session is refused when it was never
stored, or when it belongs to another directory — answering the latter
would hand back a session whose sandbox boundary points somewhere else.

`scripts/smoke.mjs --load <id>` drives the path against a real
installation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wgqqqqq
wgqqqqq force-pushed the feat/dsh-acp-bridge branch from 2afb8c0 to 4c38663 Compare August 14, 2026 16:49
@wgqqqqq
wgqqqqq merged commit fe68c03 into GCWing:main Aug 14, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant