Skip to content

feat: the DeepSeek Harness agent - #87

Merged
deymosh merged 22 commits into
masterfrom
feat/deepseek-harness-agent
Oct 7, 2026
Merged

deymosh merged 22 commits into
masterfrom
feat/deepseek-harness-agent

Conversation

@deymosh

@deymosh deymosh commented Oct 7, 2026

Copy link
Copy Markdown
Owner

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

  • Runtime on demand: the harness, a pure-JS CLI of ~600 packages, is installed into <home>/agents/ at the version and sha512 that pnpm-lock.yaml pins, like the other agents' binaries. BUNDLE_AGENTS=1 bakes it into the image instead.
  • Driver over ACP: prompts, cancellation, permission asks, and model and reasoning selection from the harness's own catalog. File changes are reconstructed from each tool call's arguments.
  • Gateways: with DEEPSEEK_BASE_URL set, the gateway's own model list becomes the harness's catalog. Provider profiles work per session, each in its own process.
  • MCP and plugins: MCP servers live in a delimited block of the profile's patch layer. Plugins are npm packages, managed through the harness's own CLI.
  • The CodeDeck plugin: slash commands, the model's questions, plan reviews and mid-turn messages are not in ACP. They reach the phone through a small CodeDeck plugin the driver writes into the profile, and each harness process has its own local socket.

Fixes from live testing

  • One socket per process: all harness processes listened on one socket path, and whichever closed first deleted the survivor's socket. Slash commands never appeared, and question answers (picked or typed) never reached the model. Each process now has its own socket.
  • Command cache: a slash command typed before the plugin answered pinned the session to "no commands".
  • OpenCode provider leak: DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL reached every agent, so OpenCode offered a DeepSeek provider nobody configured. That namespace now stays with the harness's driver.
  • Security: the gateway URL in the shared profile overrode a provider-bound session's own endpoint, which sent that profile's token to the operator's gateway.
  • Plugin CLI home: the plugin CLI ran without DSH_HOME, so phone-driven installs changed a profile no session uses. CodeDeck's plugin is also restored after every pnpm run.
  • Installer memory: the tree installer kept the whole runtime decompressed in memory at once.
  • Agents volume: /data/agents is 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_approval and request-plan-approval name their revise option.
  • A plan-response that picks it may carry feedback, which the driver receives as plan-outcome feedback. The bridge also records it as the user's message.
  • On Android, "Keep planning" opens a field for what should change.
  • The DeepSeek driver gives the feedback to the harness's plan review. Its tool asks the model to revise right away, so a follow-up message always came too late.
  • Claude Code's driver uses the feedback as the message of its refusal.
  • The corpus fixtures, display corpus, generated TS types and UniFFI bindings are updated.

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

  • Agent host: typecheck, all 500 tests and the build pass.
  • Rust: clippy (-D warnings) and cargo test --workspace pass, plus the ignored driver-protocol spawn test and the bridge end-to-end test.
  • Android: testDebugUnitTest, verifyPaparazziDebug and lintDebug pass.
  • Live, against a running bridge:
    • harness boot in about 1 s;
    • two processes with two sockets;
    • commands listed, and /plan ran as a command;
    • a question answered with free text reached the model;
    • gateway routing works with no URL in the profile;
    • OpenCode no longer sees the DeepSeek variables;
    • a message sent mid-turn was read inside the same turn.

🤖 Generated with Claude Code

deymosh and others added 22 commits October 4, 2026 22:14
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>
@deymosh
deymosh merged commit 0575ed1 into master Oct 7, 2026
6 checks passed
@deymosh
deymosh deleted the feat/deepseek-harness-agent branch October 7, 2026 15:02
@deymosh deymosh mentioned this pull request Oct 7, 2026
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