Repository navigation
feat: the DeepSeek Harness agent - #87
Merged
Merged
Conversation
The release archives and the default image ship the agent host without any agent binary: a driver downloads what it needs at the version and sha512 `pnpm-lock.yaml` pins, so a pinned agent is never shadowed by whatever the machine happens to have installed. That worked for single-platform binaries. The DeepSeek Harness runtime is a different shape: a pure-JS CLI whose ~700-package npm closure must be present and resolvable or it does not boot at all. Two pieces, both keyed to the lockfile: - `src/lockfilePins.ts` gains `packageTree()`, which walks the format-9 `snapshots:` graph from a root package pinned in one importer and emits the full closure with the `node_modules/` spot each entry must occupy. A name the closure needs at two versions is laid out the way npm itself does it — one variant at the root, preferring one required paths reach, the others nested under each parent that requires them — and the walker refuses a graph it cannot place exactly. `src/generated/dshPackages.ts` is that output, checked in beside `platformPackages.ts` and covered by the same regenerate-and-diff test. - `src/agentInstall.ts` gains `installPackageTree()`, which downloads that closure into `<cache>/<name>@<version>/node_modules/` from the configured registry (mirror included), rejecting any tarball whose sha512 does not match its pin, extracting it whole (the existing ustar/pax parser, in an extract-everything mode with the same path-traversal refusal), and installing each destination atomically through a staging directory. Downloads run in a small parallel pool; extraction is serial and shallow-first, because a nested entry lives inside its parent's directory and would race it. An interrupted install resumes, and a package that cannot be fetched is skipped only when it was reached through `optionalDependencies`. Moving a staged package into place is retried, because one filesystem this runs on refuses a directory rename that the same filesystem accepts a moment later: a bind mount from a Windows host (the container's `/data`) answers EACCES while a file just written inside the directory is still being let go of by the host side. Waiting is the whole of the answer; a rename that keeps failing, or fails for any other reason, is still a failure. A binary's own rename gets the same wait. `@deepseek-ai/dsh` joins `opencode-ai` as an exact-pinned devDependency: present so the lockfile pins its closure, absent from `pnpm deploy --prod` output, so the archives stay lean and the runtime arrives on first use. Co-Authored-By: Claude Code <noreply@anthropic.com>
A third agent next to Claude Code and OpenCode, on by default. The harness's `acp` profile is the one interface it exposes that has everything an interactive client needs — prompts, cancellation, one-shot permission asks, and a model/reasoning selection that can change mid-session; its `sdk` profile has none of the last three, and `headless` is one-shot. The driver speaks that protocol directly (`acp.ts`: JSON-RPC over the child's newline-delimited stdio, typed by the ACP schema package through a type-only import, so nothing of it ships). One `dsh --profile acp` process serves every session started in the SAME environment, and an environment is the unit because a session's provider binding is: a session bound to a gateway needs a base URL and a token the native session must not have, so a binding change means a different process rather than one connection carrying two sets of credentials. The runtime is fetched as the host starts, like the other agents', so the first session finds it ready instead of waiting on a download. Two places where the agent's own answer differs from its siblings: it has no modes, so `ask`/`default` are this driver's own (every permission to the phone, or auto-approve), and its result updates carry no diff, so a file change is reconstructed from the call's arguments. What the surface does not have is advertised as absent: no questions, no background tasks, no subscription usage. The context meter does, over the harness's own usage updates, and a provider binding reaches the child as `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` with the vendor namespace dropped from the inherited environment. MCP servers and plugins are the harness's own configuration files: rows in the profile's patch layer (a delimited block this driver owns) and `dsh plugin --profile acp` for packages. `src/provider.ts` takes the base-URL rule two drivers now apply. Co-Authored-By: Claude Code <noreply@anthropic.com>
An operator who sets `DEEPSEEK_BASE_URL` is saying "run sessions on this endpoint", but the harness takes only the endpoint from the environment: its model catalog is its own fixed list of DeepSeek model names. A gateway would be sent a name it does not know, and the phone would offer models that are not there — while the one model the harness is *on* (the profile's pinned default, which it always offers) showed up as a model nobody could run. So the gateway's own list becomes the harness's catalog. As the host starts, the endpoint is asked for its models — `<root>/v1/models`, on the same root the harness posts `/messages` to, so one setting configures both — and the list is written into the harness's profile, where the harness documents its catalog as replaceable. The entries naming the model a session starts on move with it. Clearing the variable takes the row back out; an endpoint that does not answer leaves the harness's own catalog alone, and nothing is guessed. Models are reported in the shape the other drivers use: the endpoint's own id (a router's ids name their channel before a slash), with the channel as the provider and the model as the label. The credential check asks that same list about the key, so a gateway key gets a real verdict. Both blocks live in one file and each leaves the other alone, so the profile layer's block editing moves into profileLayer.ts with a claim per block. Co-Authored-By: Claude Code <noreply@anthropic.com>
The harness has commands in this profile — the automation stack mounts the registry and `/compact`, `/goal`, `/feedback`, `/plan`, `/permission` — but its ACP surface carries none of it: ACP has no command list and no way to invoke one, the only invoker is the harness's own client API, and the automation profile mounts no transport for that. A typed `/name` was therefore prompt text that reached the model. CodeDeck brings its own transport. A small plugin of ours goes into the profile — a package in its `node_modules` and a row in its patch layer — holds the harness's command registry, and answers two questions over a local socket: what a session can run, and "run this line". The driver lists them for the phone and runs a typed `/name` there instead of prompting with it, so `/plan` answers "Plan mode on" and `/compact` compacts, both as the agent's own words in the transcript. The plugin is kept in the profile because the harness reads it as it boots; its row and its two files are rewritten when the host starts. A list that cannot be read is not an error: the phone offers no commands and a slash line is ordinary text again, and no session fails over it. Commands that take attachments get none: this bridge sends the line alone. Co-Authored-By: Claude Code <noreply@anthropic.com>
The bridge tells the agent host where each agent's own things live, and the harness is the same shape as the other two: - `deepseekPath` (`--deepseek-path`, `CODEDECK_DEEPSEEK_PATH`) names the harness CLI to run instead of the runtime this build installs on demand. - `CODEDECK_DEEPSEEK_HOME` is the harness's state root (`$DSH_HOME`: its profiles, sessions and credentials), and it follows the bridge's home the way `CODEDECK_AGENT_CACHE` already does — a bridge that moves its data directory must not leave an agent's conversations behind. Both travel to the agent host as its environment, alongside the paths already there; nothing else in the bridge learns that a third agent exists. Co-Authored-By: Claude Code <noreply@anthropic.com>
The other two agents bundle as a binary — the Claude Agent SDK's platform package is kept, OpenCode's is copied in — but the DeepSeek Harness is a package tree of some 600 packages and several hundred megabytes: nothing runs until every one of them is laid down, which is exactly what the host's own installer does. So the image does not re-implement that install with a second package manager (whose tree could differ from the pins); it runs the installer. That needs a way to run an install without serving anything, so the host gains one: `CODEDECK_AGENT_HOST_WARM=1` installs the runtimes of the enabled drivers and exits. Each agent's own lookup decides, so an agent already on the machine is left alone, and a startup that fails exits non-zero — a build step that asked for an install cannot report success without it. The tree lands in the cache the host installs into, inside the /data volume, which Docker fills from the image when it is first created. The container's own wiring gains what a third agent needs: the DeepSeek key as a compose secret (like the other two credentials, read by the entrypoint), `DEEPSEEK_BASE_URL` for a relay or gateway, and `CODEDECK_DEEPSEEK_PATH` for an operator's own CLI. `.env.example` is regrouped around that: secrets, git, the three agents, the bridge, extras. Co-Authored-By: Claude Code <noreply@anthropic.com>
`DEEPSEEK.md` covers what an operator needs: the key, the harness's own home under the bridge's, models and reasoning as the harness reports them, gateways through a provider profile or the bridge's own endpoint, slash commands and the plugin that brings them, the MCP servers and plugins it keeps in its own profile, what the automation surface does not have, and the offline and container notes. The pages that enumerate the agents now name all three — the README, the bridge's install and config sections, and the protocol's MCP notes, where the harness joins Claude Code, and the driver-adding guide. Co-Authored-By: Claude Code <noreply@anthropic.com>
The repository stores LF, but nothing said so: a checkout on Windows writes CRLF into a file it edits, and the change lands as a whole-file diff that says nothing about the code. `* text=auto eol=lf` makes the repository's form the shared one, whatever the platform that wrote the change, and leaves binaries alone. Gradle's wrapper script is the one text file that has to keep its CRLF. The two ignore files that predate this are stored as LF now too. Co-Authored-By: Claude Code <noreply@anthropic.com>
The Plugins screen listed the harness's own bundles — the shared core and the ACP application — with no version, which reads as "not installed": a profile composes those bundles from the harness's installation without copying them into its own `node_modules`, so the version is not where a user-installed plugin's is. The lookup now falls back to the runtime's own packages tree before answering nothing. Co-Authored-By: Claude Code <noreply@anthropic.com>
The harness's model can put a decision to the user — its question tool and its plan review both ask through the same `user-questions` service — and that service's answerer is a panel in the harness's own apps. A plain setup has none, so the tool fails with "no user-questions answerer configured" wherever a model tries to ask. It is the same shape of gap as commands: the capability is there, the automation surface simply never carries it. The plugin CodeDeck already installs for commands becomes the bridge for both (it is `codedeck-dsh-bridge` now, on `dsh-bridge.sock`): it composes an answerer on the harness's question waterfall, pushes the question to the host as a marker line on stderr — the one stream ACP does not own — and returns the answer it gets back on the socket. The phone shows the question as the very card the other agents' asks use, and the answer travels in the shape the harness takes: the labels of the chosen options, or the text the user typed, one item per question keyed by the question's own id. An unanswered question (the phone gone, the user cancelling, the turn ending) is answered with nothing, which the harness reports to the model as a question that was not answered — never a hang. The question tool's own transcript row is hidden, since the card is the exchange. Co-Authored-By: Claude Code <noreply@anthropic.com>
The harness's `exit_plan_mode` presents a finished plan through the same `user-questions` service the question tool uses, and it asked in a shape the answerer did not read: a plan review names no call id where the plugin looked for one, so the ask was left unanswered and the tool died reading `answers` off nothing. That id is on the question's intent for this ask, and a request this bridge cannot show at all is now handed down the chain with `next()` — a waterfall listener that merely returns has vetoed the chain, which is what turned an unreadable request into that crash. Reaching the phone, a plan review is shown the way Claude Code's is, since it is the same exchange: the plan as a plan of its own, and the choice as the approval card, wearing the harness's own labels — that label is the verdict the tool reads. A plan the user did not approve goes back to the model to revise, with their feedback arriving as their next message, as it does for the other agent. The plan review's own tool rows are hidden with the question tool's: the plan would otherwise be in the transcript a second time, as raw arguments. Co-Authored-By: Claude Code <noreply@anthropic.com>
The model kept reporting that it had no `ask_user_question`, and it was right: the harness's shared core registers the question *service* and the plan-mode tool that presents a plan through it, but the tool that asks belongs to the web app's agent presets, which an automation profile never composes. A headless session therefore ran a model that plan mode's own instructions told to ask the user with a tool it did not have. The driver now mounts it in the profile's patch layer, bare, the way the harness's own presets do. The row names the harness's own package, so nothing has to be installed into the profile for it, and a harness that drops that package costs the tool — an entry that cannot be imported is a warning at boot, not a failure — never the session. `present`, the web app's deliverables tool, is deliberately left out: nothing on the phone would show what it declares. Writing that block exposed a second bug, in the patch-layer editor every one of these blocks shares: it took a block out and re-appended it at the end, so a layer rewritten at every start grew by a blank line on every start, and our blocks drifted through a file a person reads. A block is now replaced where it stands, which makes a start that changes nothing leave the file byte for byte as it was. Co-Authored-By: Claude Code <noreply@anthropic.com>
The runtime runs one harness process per environment, so several are up at once: a session whose environment differs from the model probe's (a stored GitHub token is enough) or one bound to a provider profile gets a process of its own. Every one of them mounted the CodeDeck plugin from the same profile row, with the same socket path. The second plugin unlinked the first one's socket as it started, and whichever process closed first unlinked the survivor's, leaving a live harness nothing could reach: the question cards still reached the phone (they travel on stderr), but every answer, every command list and every slash command failed with "the command bridge is not running". The path is now minted per spawn and handed to the process in its environment, never in the shared profile; each session asks the process that holds it. The plugin takes questions only while it is listening, and a harness started without a host (the plugin CLI, a person running the profile by hand) takes none, so its questions fail the harness's own way instead of waiting forever. A killed process's stale socket file is removed when it exits. Co-Authored-By: Claude Code <noreply@anthropic.com>
… read The list of what a session's harness can run is cached for the session, and only a rejection cleared it. A list that could not be read resolves to nothing rather than rejecting, so one slash line typed before the plugin answered pinned the session to an empty command set: every later slash line went to the model as text, even once the plugin was there. Only a list that was actually read is kept now. Co-Authored-By: Claude Code <noreply@anthropic.com>
The bridge exports DEEPSEEK_API_KEY (and, for a gateway, DEEPSEEK_BASE_URL) for the harness, and the agent host passed its whole environment on to every agent it starts. OpenCode switches on a DeepSeek provider of its own for any DEEPSEEK_API_KEY it finds, so installing the harness made OpenCode offer DeepSeek models nobody configured there, billed to the harness's key. The harness's driver now takes the DEEPSEEK_* namespace for itself before any driver starts a process, and the other agents inherit the environment without it. Co-Authored-By: Claude Code <noreply@anthropic.com>
The gateway catalog row wrote the operator's DEEPSEEK_BASE_URL into the llm-deepseek entry of the harness profile as its baseURL. Every harness process reads that profile, and the route takes a configured baseURL over DEEPSEEK_BASE_URL in its environment. A session bound to a provider profile therefore sent its requests, and that profile's token, to the operator's gateway instead of the endpoint its own environment named. The row now carries the catalog and the starting model only. The operator's process already finds the gateway in its environment, which is the one place that is that process's alone. Co-Authored-By: Claude Code <noreply@anthropic.com>
The plugin manager ran `dsh plugin --profile acp` with the host's own environment, which names the harness home only as CODEDECK_DEEPSEEK_HOME; DSH_HOME, the variable the harness reads, is set per session spawn. So an install, removal or update from the phone worked on the default home's profile, which no session runs, while the manager read back the real one and showed nothing changed. The CLI now runs with DSH_HOME set to the sessions' home. And since it runs pnpm in the profile, whose node_modules also holds CodeDeck's own plugin (a package no lockfile lists), CodeDeck's part of the profile is put back after every run. Co-Authored-By: Claude Code <noreply@anthropic.com>
…tion The tree installer fetched every tarball of a runtime in parallel and inflated each one as it arrived, so the whole runtime sat decompressed in memory until the serial extraction phase: for the DeepSeek Harness (over 500 MB on disk) the better part of a gigabyte, on a machine that may be a small VPS. The tarballs are now kept as they were downloaded and inflated one at a time as each is laid down. Co-Authored-By: Claude Code <noreply@anthropic.com>
The image puts the agents a BUNDLE_AGENTS=1 build carries in /data/agents and relied on Docker filling the /data volume from the image, but the compose file mounts a host directory there, which Docker never fills: the bundle never reached a deployment. And on Docker Desktop that host directory is a file share slow enough to dominate a runtime of hundreds of packages — the DeepSeek Harness, which reads its whole tree as it boots, took about 25 seconds to start from it against about one from a volume, on every new harness process. /data/agents is now a named volume (`agents`), which Docker fills from the image on first creation and which lives on Docker's own filesystem. The image hands the directory to the runtime user, since a volume takes the ownership the image gives it and the host installs into it. Co-Authored-By: Claude Code <noreply@anthropic.com>
A plan approval could only carry a choice. "Keep planning" sent the plan back with nothing in it, and the user's feedback had to follow as a separate message — which the DeepSeek Harness never waits for: its plan review asks the model to revise at once, so the model started a revision without the feedback and the message queued behind that turn. The plan approval now names its `revise` option (the phone wire's plan_approval entry and the driver protocol's request-plan-approval), and a plan-response choosing it may carry `feedback`. The bridge forwards it only with that option, as the new plan-outcome's `feedback`, and records it in the transcript as the user's own message. On the phone, choosing the revise option opens a field for what should change; sending it empty keeps planning with no feedback, and the other choices stay a tap away. The DeepSeek driver hands the feedback to the harness's plan review as the answer's free text, which its tool passes to the model with the request to revise. Claude Code's driver makes it the message of the refusal that keeps the agent planning. Co-Authored-By: Claude Code <noreply@anthropic.com>
…w message ACP takes one prompt at a time, so a message sent while the harness was working waited until the whole turn had ended, unlike Claude Code, whose SDK hands a message to the running turn. The harness has that itself: its agents take a message for the running turn's next step (its own apps steer with it). The CodeDeck plugin now offers it over its socket, building the message with the harness's own constructor, found from the CLI the process was started with. While one of its prompts is in flight, the driver steers a message into that turn, and the model reads it at its next step. A slash command, or a message the turn can no longer take (it is ending, or the plugin cannot steer), waits for the next turn as before, and so do the messages after it, so none overtakes another. Also correct the driver's description of what the automation profile lacks: its commands and questions reach the phone through the plugin. Co-Authored-By: Claude Code <noreply@anthropic.com>
The plugin and client tests listened on a socket file in a temp directory, which Windows cannot do: a local socket there is a named pipe. The socket-path tests also assumed the host's default was the POSIX layout. Tests now use a pipe on Windows and name the platform whenever a path's layout is what they check. Co-Authored-By: Claude Code <noreply@anthropic.com>
Merged
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.
Summary
Adds the DeepSeek Harness (
dsh) as a third agent, driven over its ACP profile (dsh --profile acp), plus the fixes and protocol work its first live use called for.The agent
<home>/agents/at the version and sha512 thatpnpm-lock.yamlpins, like the other agents' binaries.BUNDLE_AGENTS=1bakes it into the image instead.DEEPSEEK_BASE_URLset, the gateway's own model list becomes the harness's catalog. Provider profiles work per session, each in its own process.Fixes from live testing
DEEPSEEK_API_KEYandDEEPSEEK_BASE_URLreached every agent, so OpenCode offered a DeepSeek provider nobody configured. That namespace now stays with the harness's driver.DSH_HOME, so phone-driven installs changed a profile no session uses. CodeDeck's plugin is also restored after every pnpm run./data/agentsis now a named Docker volume. Bundled agents never reached the default bind mount, and on Docker Desktop that mount made every harness boot take about 25 s instead of about 1 s.Protocol: feedback with a plan sent back
plan_approvalandrequest-plan-approvalname theirreviseoption.plan-responsethat picks it may carryfeedback, which the driver receives asplan-outcomefeedback. The bridge also records it as the user's message.Messages during a turn
ACP takes one prompt at a time. While a DeepSeek prompt is in flight, a new message is steered into the running turn through the plugin, the way the harness's own apps do it. A slash command, or a message that arrives as the turn ends, waits for the next turn without overtaking anything.
Testing
-D warnings) andcargo test --workspacepass, plus the ignored driver-protocol spawn test and the bridge end-to-end test.testDebugUnitTest,verifyPaparazziDebugandlintDebugpass./planran as a command;🤖 Generated with Claude Code