feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode - #337
Merged
Conversation
Adds `mode: 'deepseek'` alongside claude/shell/opencode/codex/gemini/ antigravity/pi/grok, plus a shortcut that opens the harness's own browser UI as a Codeman web tab. DeepSeek is wired unlike its siblings in three ways, each of which is the reason for a design decision rather than an accident: 1. The agent is a PROFILE, not the binary. `dsh` is a launcher over $DSH_HOME/profiles/<name>, and DeepSeek ships only `web`, `headless` and `base` -- the interactive terminal front door is always a third-party plugin. So availability is two questions: `isDeepSeekAvailable()` (binary) and `isDeepSeekRunnable()` (binary AND a pane-capable profile). The Run button gates on the latter, because reporting only the binary would spawn a pane that dies on arrival. When the binary is present but no profile is, the run menu offers to install one (POST /api/deepseek/install-profile). 2. The permission switch is an env var, not a flag. The harness has no command-line permission option; its sandbox/approval rows read DSH_PERMISSION_MODE (read-only / workspace-write / danger-full-access). Exported via `tmux setenv`, never on the spawn line. Absent = the harness's own workspace-write, which still asks, so the multi-user clamp is the only-if-sent branch and clamps to workspace-write, never read-only. 3. It is the only non-claude mode that passes hooksAvailableForMode(), and it earned that. The terminal front door reports idle/working/blocked to a supervising process over a generic env-gated contract; a generated shim (deepseek-status-shim.ts) makes Codeman that supervisor and forwards each report to /api/hook-event as stop / agent_working / permission_prompt. So a dsh session gets definitive respawn triggers, real wait-endpoint signals and real Approvals Inbox items instead of output-stabilization guesswork. `agent_working` is new (157th SSE constant) and joins APPROVAL_RESOLVING_EVENTS so a dialog answered in the terminal clears its alert at once. The resolver needs the strictest identity probe of the family: `dsh` is not merely a squattable npm name, Debian ships an unrelated `dsh` (dancer's shell), so `dsh --help` must print the harness's own banner before a candidate is handed a spawn line. Model is deliberately not a session field -- it is a composition entry in the profile's config tree. Env allowlist gains DSH_* and DEEPSEEK_* only; provider keys named by a settings-file `apiKeyEnv` stay out, which is pi's 34-provider-key problem in a new shape. Verified live against dsh 0.1.1-rc.2 and @deepseek-harness-tui/dsh-tui: the status endpoint's two-part answer, the no-profile refusal, the profile bootstrap, a real session whose pane runs `dsh --profile dsh-tui` with the permission mode injected via setenv, and the full status bridge -- a send-and-wait returned signal "stop" from a real turn, and blocked/working created and cleared an Approvals Inbox item. Docs: docs/deepseek-integration.md (guide), docs/deepseek-integration-plan.md (decisions + honest gaps). Tests: test/deepseek-mode.test.ts, test/deepseek-cli-resolver.test.ts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l, make the hook gate per-session Three review findings on the DeepSeek Harness mode, plus one the third exposed. 1. The multi-user clamp was bypassable by a sibling field on the same request. clampExternalCliBypassForOwner() clamps deepSeekConfig.permissionMode, but DSH_* is an allowlisted envOverrides prefix and applyEnvOverrides() runs AFTER _configureDeepSeek(), so a non-granted owner sending envOverrides.DSH_PERMISSION_MODE landed last and won. Measured on an isolated instance: a session created with permissionMode "read-only" and that override ran with DSH_PERMISSION_MODE=danger-full-access in its pane. Every other CLI's bypass is a command-line flag reachable only through the per-CLI config, which is why the config clamp alone is the whole gate for them. clampEnvOverridesForOwner() adds the env-var half: for a non-granted owner it DROPS DSH_PERMISSION_MODE and DSH_HOME (dropping falls through to what _configureDeepSeek() exports, i.e. the clamped value). DSH_HOME is on that list because it aims the launcher at a profile tree whose plugin code runs at boot, before any approval row can apply. Verified end to end in real multi-user mode: a non-granted user sending both now gets workspace-write and no DSH_HOME, while an unrelated DSH_TELEMETRY_MODE passes through untouched. 2. POST /api/deepseek/install-profile could hang forever. spawn's own `timeout` signals only the direct child, and a plugin install fans out into package-manager children that keep the inherited stdio pipes open, so `close` never fires and the held-open request leaks with no route-level deadline. Reproduced: with a 1.5s built-in timeout the promise was still unsettled after 6s and both fan-out children were alive. Now detached: true plus negative-pid SIGTERM/SIGKILL, the same escalation runGit() uses for the same reason, with a last-resort reap for a grandchild that escaped the group. Same probe after the change: close fires, direct child and both grandchildren dead. 3. hooksAvailableForMode() promised more than a dsh session can deliver. deepSeekConfig.statusReporting: false disarms the HERDR_* export, and that triple is the only reason a dsh session posts hook events, so `until=stop` was accepted and then blocked for the caller's whole timeout: the exact infinite-wait-dressed-as-a-timeout the predicate exists to prevent. It now takes HookCapabilityOptions and every call site passes sessionHookOptions(), with the deepseek arm reading `!== false` so a forgotten one degrades to the old behaviour. The refusal names the setting rather than saying "no Claude Code hooks", which would send the caller hunting a bug that is really a setting they chose. Profile conformance stays unknowable at request time and is documented as such. The stale "True for `claude` and nothing else" docblock is corrected. 4. Exposed by (3): hooksAvailableForMode() was doing double duty as "is this a claude session". Read My Mind (POST /api/sessions/:id/readmymind) and intent capture read Claude's own transcript, and adding deepseek silently widened both to a mode that has none. They compare mode === 'claude' directly now, and a static check pins them there. Verified: full CI gate green (6132 passed), typecheck/lint/format clean, and the wait-signal gating exercised against a live server with a real dsh 0.1.1-rc.2 -- bridge off plus explicit until=stop is a 400 naming the setting, bridge off with no `until` still 200s on idle/exit, bridge on accepts stop.
…llback profile classifier The three smaller review nits, plus the first real test coverage for the status shim (it had none: it is emitted as a STRING, so tsc never sees it). 1. The shim was written with a plain writeFileSync. The TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a reader catching a half-written file gets a syntax error, exits non-zero, and is retried four times per state change for a file that will never parse. Now temp + rename (atomic within the directory), with the temp chmod'ed before the rename since writeFileSync's mode only applies on create, and removed if the write throws. SHIM_VERSION bumped to 2, because SHIM_SOURCE changed and an existing v1 shim would otherwise keep matching the embedded marker and never be refreshed. 2. The pane-id comment claimed the ambient env "cannot be spoofed by an argument the agent itself could influence". The agent runs IN that pane and can invoke the shim with CODEMAN_SESSION_ID unset and any argv it likes. It buys nothing it did not already have (the hook-secret file is readable from the same pane, so it can POST /api/hook-event directly), but the comment read like a security boundary. Rewritten to say what the preference actually buys: correct attribution when a TUI mangles or re-uses the pane argument. Accidents, not adversaries. 3. classifyProfile() folded the directory name into the same haystack as the bundles, but only the TUI arm could match a bare name, so a stock profile whose package.json has no dsh.profile.bundles (hand-edited, older layout, mid-install) classified as `unknown` -> launchable -> eligible as the DEFAULT pick, which is exactly the pane-dies-on-arrival failure the two-part availability gate exists to prevent. The stock names are now a LAST-resort fallback consulted after the bundle patterns, so real bundle evidence still wins over a name the user chose. The loose `tui` arm gained word boundaries: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. New test/deepseek-status-shim.test.ts runs the generated script the way the harness does -- real node process, real argv, real env, real listener -- and covers the exit-code contract that makes the retry behaviour safe: mapped states post and exit 0, an unknown verb or unmapped state exits 0 WITHOUT posting (a non-zero there would be four HTTP requests per state change forever), a rejecting server or an unreachable one exits non-zero so the caller retries, the hook secret is read at execution time, and `node --check` parses the file (a template-literal typo in SHIM_SOURCE is invisible to tsc). Trap worth recording, hit while writing it: the tests must spawn the shim ASYNCHRONOUSLY. The listener lives in the test process, so spawnSync blocks the event loop that has to accept the connection, the shim waits out its own 1500ms socket timeout and exits 1, and it reads exactly like a broken shim (measured: Socket._onTimeout in its --trace-exit output, server logging nothing). Verified: full gate green (6142 passed, +10), typecheck/lint/format clean.
…and trust its frame
The `Run > DeepSeek web UI...` shortcut failed three ways at once against a real
install, and the three are independent.
1. It hardcoded `--port 3080`. That is dsh web's OWN default, which makes it
precisely the port a DeepSeek user is most likely to be serving on already,
so the launch died with EADDRINUSE against the user's own server. The port
now comes from `GET /api/deepseek/web-port`, which walks 3080..3119 for a
free loopback port by BINDING it (a connect probe cannot tell "free" from
"listening but not answering yet").
2. It opened the tab unconditionally. The crashed server left a saved dashboard
pointing at nothing, with the failure only visible in a shell tab nobody had
a reason to look at. The launch now polls the existing webview probe until
the URL answers, and on timeout reports the error naming the shell tab
instead of persisting a dead dashboard.
3. The saved tab was untrusted, so the frame was sandboxed without
`allow-same-origin` and the dashboard was broken twice over: the dsh
client-runtime reads `localStorage` while loading its plugins and died there
("the document is sandboxed and lacks the 'allow-same-origin' flag"), and an
opaque-origin frame sends `Origin: null`, so dsh's own trust fence 403'd
every `/api` call no matter which authority `--trusted-host` named. Passing
`location.host` only means anything once the frame actually carries that
origin, so `--trusted-host` had never once done its job. The managed tab is
now created `trusted: true`.
That trade is real and deliberate: a trusted proxied frame is same-origin
with Codeman and can reach Codeman's API. It is defensible only because this
dashboard is an agent harness Codeman just started itself, on loopback, which
can already run code as the user. It is not a precedent for trusting
third-party dashboards, which is why it is set at this one call site rather
than defaulted.
Separately, the shortcut listed its own dashboard twice: once as the menu entry
that starts it and once as the row that entry had written on the previous click.
Webviews now carry an optional `managed` marker, managed rows are filtered out
of the saved-dashboard list, and a relaunch repoints the existing row rather
than stacking one dead dashboard per restart (which the per-launch port would
otherwise guarantee). `managed` is declared in the schema because a plain
`z.object` strips undeclared keys, so an undeclared marker would never survive
the round trip.
`DEEPSEEK_WEB_PORT` is gone from constants.js; its doc comment asserted that a
hand-started `dsh web` and the shortcut "land on the same place and share one
saved tab", which is the bug stated as a feature.
Verified on a real install with the user's own `dsh web` holding 3080: the
shortcut takes 3081, the server answers, exactly one DeepSeek entry shows in the
run menu, and the proxied dashboard renders its workspaces and completes its own
API calls (the previously-403'd `api/settings.describe` now succeeds). Full gate
green (6142 passed), typecheck/lint/format/public-assets clean.…l tab Clicking "DeepSeek web UI..." opened two tabs: the web tab asked for, and a shell tab running the server next to it. The shell was deliberate - the server lived in an ordinary session so it was visible, scrollable, killable and died with its tab, and nothing new had to supervise a long-lived HTTP server. That reasoning was sound and the result was still wrong in use: opening a dashboard should open one tab, and after the first launch the terminal is pure noise. The server moves to a background child process owned by a new `src/deepseek-web-server.ts`, behind `POST /api/deepseek/web`. What the session gave away for free is now explicit, which is most of the module: - Exactly one server. A second click reuses the running one instead of racing it for a port; the session flow could not do this at all, because two clicks were simply two sessions. - Restarted when the requested authority changes. `--trusted-host` fences dsh's own /api against the browser authority, and a Codeman reachable at both loopback and a tailnet name has two. Reusing a server fenced for the other origin renders a page whose every call 403s, which reads as a broken dashboard rather than a misconfigured one, so a mismatch restarts instead. - Killed on shutdown. The child is detached so its whole plugin tree can be signalled at once, which also means it would outlive Codeman and hold its port against the next start - the exact EADDRINUSE this feature already got wrong once. - Boot output captured and returned. With no shell tab there is nowhere else for a stack trace to land, so a failed spawn reports its own tail. The endpoint is fenced at the same bar as the profile installer and for the same reason: booting a dsh profile executes the plugin code in it, so this is a privileged action even though it reads as "open a page". `authority` comes from the client (`location.host`) because only the browser knows which origin is in play, and it is regex-confined at the schema boundary - defence in depth behind the argv-array spawn, admitting host:port in the shapes a browser authority can take and nothing readable as a second argument. `GET /api/deepseek/web-port` is gone; port selection moved into the supervisor, which is the thing that knows whether a server is already running. The two client-side probe helpers went with it, since the server now owns the wait. Verified over the tailnet authority end to end: no session is created (session count unchanged, one tab), the server runs on 3081 beside the user's own dsh web on 3080, status reports the tailnet authority, and the proxied dashboard renders with zero 4xx. Full gate green (6148 passed, +6).
Fifteen review findings on the dsh mode, the serious ones first: - Multi-user: DEEPSEEK_BASE_URL joins the owner-clamped env keys. _configureDeepSeek() forwards the SERVER's own DEEPSEEK_API_KEY into every dsh pane and applyEnvOverrides() lands after it, so a non-granted owner who could redirect the base URL would have the operator's key sent as a bearer credential to a host of their choosing. - Wait registry: until=stop/blocked is refused on docker and remote-SSH dsh sessions (new deepSeekBridgeUnreachable fact in sessionHookOptions). The HERDR triple is set via LOCAL tmux setenv, which crosses neither docker exec nor ssh, so such a session can never post a hook event and the wait burned its whole timeout on every turn. - Approvals: a dsh item is an ALERT, not an answerable card. The answer route refuses (the '1'/Esc keystrokes are Claude-dialog-shaped and the option parser cannot read a third-party TUI's frames, so an answer was a blind keystroke into a foreign composer), and the push notification carries no Approve/Deny actions for dsh sessions. - Status shim (v3): --seq is forwarded and the server drops stale retried reports inside a 60s window (the TUI retries with backoff, so a retried 'working' could land after 'blocked' and resolve an approval whose dialog was still on screen); 4xx responses exit 0 instead of retrying, so one misconfigured session cannot feed the auth rate-limit bucket until the hook endpoint 429s for the whole instance. - Web-UI server: concurrent starts are serialized through a lock (two racing POSTs used to pick the same port and orphan the winner), and the readiness poll / timeout paths only clear or stop the singleton while it is still theirs. First click actually opens the tab now (refreshWebviews, not the nonexistent loadWebviews). DELETE /api/deepseek/web requires the privileged grant in multi-user mode. - Cron: deepseek jobs run the same two-part launch gate as the HTTP create paths (impl moved into the resolver so all three share it) and no longer stamp a Claude default model on the session. - Parity sweeps: quick-start's docker branch rejects deepSeekConfig like the remote branch; the Ralph auto-enable list gained deepseek; HookEventType gained agent_working; the phone overview run menu filters managed webview records like the desktop menu. - install.sh: the dsh identity probe closes stdin (under curl|bash a child that reads stdin eats the rest of the script), bounds the exec with timeout where available, and is memoized to one scan per install. - Welcome screen: .welcome-btn-deepseek styled in the #4d6bfe brand identity (it rendered as an unstyled UA-grey button); stale markup comment about the web shortcut rewritten; clamp docs updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 freeto 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 adds
DeepSeek Harness (
dsh) as a ninth Codeman run mode, alongside Claude Code, shell, OpenCode, Codex, Gemini, Antigravity, Pi and Grok. Plus a shortcut that opens the harness's own browser UI as a Codeman web tab.DeepSeek Harness is DeepSeek's plugin-native agent framework, open-sourced 2026-08-13 under MIT. Everything in it (tools, skills, sandboxes, whole apps) is a plugin composed into a profile.
Why the wiring looks unlike the other eight CLIs
Three facts about the harness drove the design. None of them holds for any previous mode, so this is worth reading before reviewing the diff.
1. The agent is a PROFILE, not the binary
dshis a launcher:dsh --profile <name>boots an ordered stack of plugin-bundle patch layers from$DSH_HOME/profiles/<name>. DeepSeek ships exactly three bundles and none of them is a terminal agent:web(browser UI),headless(one-shot, exits) andbase(no app at all). The interactive terminal front door is always a third-party plugin the user installs.So "is dsh installed" and "can Codeman start a session" are different questions, and the code answers both:
isDeepSeekAvailable(), the binary resolved and proved its identityisDeepSeekRunnable(), the above AND a profile that can occupy a paneThe Run button gates on
runnable. Gating on the binary alone would spawn a pane that dies on arrival, which is this mode's most confusing possible failure. When the binary is present but no usable profile is, the run menu shows "DeepSeek, add a terminal profile..." instead of a greyed-out entry, because there is a 30-second fix and the user should be offered it.resolveDefaultDeepSeekProfile()prefers a recognised TUI, then an unrecognised profile, and refusesweb/headless. Treating unknown as launchable is deliberate: anyone can publish an app bundle, so a classifier that has not heard of one must not hide it.2. The permission switch is an env var, not a flag
The harness has no
--dangerously-skip-permissionsequivalent. Its sandbox and approval rows are configuration, driven by one input,DSH_PERMISSION_MODE, with three presets (read fromdsh --dump-default-config):DSH_PERMISSION_MODEread-onlyworkspace-writedanger-full-accessIt is exported via
tmux setenv, never on the spawn line, and a test pins that nothing permission-shaped ever reaches the command line.This is the one legitimate exception to the repo's ban on carrying agent settings as env vars.
CLAUDE_CODE_EFFORT_LEVELis banned because it hard-locks in-session switching; the harness reads this one with??as a boot-time default, so it stays soft and the user can still change mode inside the session.Because omitting it leaves the harness on
workspace-write(which still asks), the multi-user clamp only needs the "only if sent" branch, like codex/antigravity/grok rather than pi. It clamps down toworkspace-write, notread-only: the clamp exists to remove privilege, not to break a session's ability to edit its own workspace.3. It is the first non-Claude mode with REAL idle detection
Every external CLI mode so far is readiness-guessed: Codeman watches the PTY go quiet and infers a turn ended. Claude is the exception because Claude Code fires hooks.
DeepSeek is the second exception. The community terminal front door already reports its own lifecycle to a supervising process through a fully generic, env-var-gated contract (inherited from Herdr). With
HERDR_ENV=1,HERDR_BIN_PATHandHERDR_PANE_IDset it runs, on every state change:and treats exit 0 as delivered. So
deepseek-status-shim.tsgenerates a small script into the data dir, pointsHERDR_BIN_PATHat it, and forwards each report toPOST /api/hook-event:blockedpermission_promptidlestopwaitreturnsworkingagent_workingThis makes
deepseekthe only non-Claude mode passinghooksAvailableForMode(). It earned that by emitting definitive signals rather than having them inferred.It is an interface implementation, not an impersonation: no real
herdrbinary is ever executed,HERDR_BIN_PATHis our own script in our own data dir. A terminal profile that does not implement the contract simply never calls the shim and falls back to output stabilisation, so the feature is inert rather than harmful there. Per-session opt-out viadeepSeekConfig.statusReporting: false.agent_workingis a new SSE event (the 157th) and the only hook event with no Claude Code hook behind it. It exists because a harness turn cannot run while one of its own modal approvals is on screen, so "the agent started working" proves the dialog was answered in the terminal. It joinsAPPROVAL_RESOLVING_EVENTS. Without it a dsh red alert would survive until the nextstop, which is exactly the stuck-alert bug the Claude path already had to fix once, and the pane-capture staleness sweep that fixed it there is Claude-dialog-shaped and cannot help here.The resolver needs the strictest identity probe of the family
dshis not merely a squattable npm name likepiorgrok. Debian and Ubuntu ship an unrelateddsh("dancer's shell",apt install dsh), which would answer a version probe convincingly and then be handed a spawn line. So every candidate must first print the harness's own help banner (DeepSeek Harness) before a version is even read.test/deepseek-cli-resolver.test.tspins both the rejection and the VITEST hermeticity gate with a real executable fixture.Other decisions worth noting
agent-default-model) in the profile's config tree, set in~/.dsh/settings.yamlpluscordis.patch.yml. Both create paths deliberately resolve no model for this mode rather than inventing a flag.DSH_*andDEEPSEEK_*only. A dshsettings.yamlcan nominate any env var as a provider credential viaapiKeyEnv, and the allowlist is one global list applied with no mode context, so admitting those would widen it for every mode at once. This is pi's 34-provider-key problem in a new shape, and gets the same answer.POST /api/deepseek/install-profileis the only endpoint in Codeman that installs third-party code, so it is fenced: the specifier is regex-confined to an npm name at the schema boundary (no path, URL, git spec or leading dash), the spawn is an argv array with no shell, and multi-user mode requires the can-bypass-permissions grant (the same bar as ashellsession, which can already do strictly more). The default package is a default, not a hardcoding.dshand bootstraps a profile into the agent HOME. Profiles are deliberately not seeded from the host (each is a per-profilenode_modulestree, host-arch-specific and far too large to copy per container start); only~/.dsh/.env,settings.yamlandcordis.patch.ymlare seeded.Run > DeepSeek web UI...startsdsh webin an ordinary shell session and opens the URL as an ordinary web tab, so nothing new supervises a long-lived HTTP server.--trusted-hostis load-bearing: dsh fences its/apibehind a browser-trust check on the request authority, and a web tab reaches it through Codeman's origin via the webview proxy.Verification
Full CI gate green (313 files, 6124 tests), plus typecheck, lint, frontend-syntax, public-assets and lockfile checks. 31 new unit tests across two files.
Live-verified on an isolated instance (own data dir and tmux socket) against a real
dsh 0.1.1-rc.2and@deepseek-harness-tui/dsh-tui:GET /api/deepseek/statusreturned the two-part answer correctly:available: true, runnable: falsewith onlywebandheadlessinstalled, both classified correctly.POST /api/deepseek/install-profileproduced a genuine profile (29MB, real lockfile), andrunnableflipped to true.dsh --profile dsh-tui, withDSH_PERMISSION_MODE=danger-full-access,HERDR_ENV,HERDR_BIN_PATHandHERDR_PANE_IDall confirmed present viatmux show-environmentand absent from the command line. The TUI rendered.signal: "stop"from a real turn (21.9s, not a timeout), and drivingblockedthenworkingthrough the shim created and then cleared an Approvals Inbox item. Unknown verbs and unmapped states exit 0 without posting, so there is no retry storm.Not verified (honest gaps, all recorded in the plan doc)
Request timed out) because the box's configured local provider was down. The signalling chain is proven end to end; a successful model round-trip is not.'buffer'policy against the TUI's real composer. If it turns out per-keystroke reactive like codex's, the fallback is the'off'branch.mode: 'deepseek'(needs a--no-cacheagent-image rebuild).--trusted-host <codeman-authority>is right in every deployment shape.Docs
docs/deepseek-integration.md, user guidedocs/deepseek-integration-plan.md, every decision with its reasoning, plus the gaps aboveCLAUDE.mdanddocs/architecture-invariants.mdupdated with the rules that prevent mistakes hereNote for the release
No changeset is included: COM writes one covering everything since the last tag, and a merged-but-unconsumed changeset turns the Release workflow red.