feat(acp): ship an ACP bridge for DeepSeek Harness - #2291
Merged
Conversation
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>
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
force-pushed
the
feat/dsh-acp-bridge
branch
from
August 14, 2026 16:37
a690ff7 to
2afb8c0
Compare
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
force-pushed
the
feat/dsh-acp-bridge
branch
from
August 14, 2026 16:49
2afb8c0 to
4c38663
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-acpis 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 addspackages/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 asdsh --profile bitfun-acp.The desktop bundle carries
packages/dsh-acp/dist-profileas a Tauri resource;client/dsh_profile.rsmaterializes 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.ymlmounts the harness's owndsh-settings-file,dsh-credentials-local, anddsh-agent-default-modelrows. 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.envand 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
dshpreset added in #2272, keeping the iddsh. 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--configpath. 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:
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.ModelSelector.tsx, +423): an agent may publish models, amodeconfig 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_turnvalidates a turn against the runtime's history branch for that session, and an externally driven session has none, so a first turn failed withOutcomeUnknown. It now persists straight through for a session whoseprovidermetadata 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 onlysession/newto fall back on — every stored session in my install carriesacpResumeStrategy: "new"for that reason.packages/dsh-acpnow implementssession/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-acpis deliberately not a pnpm workspace member. It pins the whole harness0.1.0-rc.6train and carries its ownpackage-lock.json, so installing the harness is the desktop bundle build's cost rather than every contributor'spnpm install.scripts/prepare-dsh-profile.mjsrunsnpm ci+tsc+ the profile packaging as part offrontend: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=1opts out on purpose.One note for anyone touching the pins: half of these packages still carry an npm
latestof0.0.1-rc.1whilenextpoints at0.1.0-rc.6. An unpinned install mixes the two trains and disagrees with itself; that is why every version here is exact.Verification
npm ci→tsc→ profile packaging in an empty tree, from the registry alone, no local harness checkout.dshinstall: handshake,session/new, three modes offered, then a full turn with streamed reasoning, abashtool call, its result, andend_turn— with the key only in$DSH_HOME/.credentials.yaml.session/loadagainst that install:loadSessionadvertised, a stored conversation replayed as user message → reasoning → tool cards → assistant text, and one locked mode returned.packages/dsh-acp: 30 vitest tests,tsc --noEmitclean. 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 newproduct_runtimepersistence test,cargo check -p bitfun-desktop,rustfmton the touched files andcargo clippyon 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 suppliesdsh, the model, and the key, exactly as a local one does; ifdshis missing or too old there, the launch fails with that sentence instead of with a missing-profile error from the launcher.