Files
Codeman/docs/deepseek-integration-plan.md
T
Codeman maintainer 2034719d61 fix(deepseek): close the env-var clamp hole, bound the profile install, 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.
2026-08-24 16:01:02 +02:00

14 KiB

DeepSeek Harness (dsh) integration plan

Status: Executed. This document records the plan, the decision behind each wiring point, and what was and was not verified. The user-facing guide is deepseek-integration.md; the per-decision invariants live in architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek. Template: the grok integration (grok-integration-plan.md), itself calibrated against pi. Every fact below was measured against a live dsh 0.1.1-rc.2 install and @deepseek-harness-tui/dsh-tui 0.9.0, not read from documentation.

1. What the DeepSeek Harness is

deepseek-ai/deepseek-harness (open-sourced 2026-08-13, MIT) is a plugin-native agent framework: tools, skills, sessions, sandboxes and whole APPS are Cordis plugins composed into profiles. dsh is the launcher — dsh --profile <name> boots $DSH_HOME/profiles/<name>, an ordered stack of plugin-bundle patch layers under the user's own overrides. State lives in ~/.dsh (.env 0600, settings.yaml, cordis.patch.yml, profiles/, sessions/, storages/).

2. Shape decisions (why DeepSeek is wired the way it is)

DeepSeek is a ninth run mode. Never a location overlay, never a web tab (the browser UI is handled separately, §3). Three of its decisions have no precedent in the six external CLIs before it.

Question Decision Why
What does a pane run? dsh --profile <name>, profile discovered The decision that shapes everything else. DeepSeek ships web, headless and base — no terminal agent. The interactive front door is always a third-party plugin, so Codeman resolves a binary AND a profile inventory, and "available" means both. resolveDefaultDeepSeekProfile() prefers a recognized TUI, then an UNRECOGNIZED profile (anyone can publish an app bundle; a classifier that has not heard of one must not hide it), and refuses web/headless, which cannot occupy a pane.
Which TUI? none blessed; default for BOOTSTRAP only POST /api/deepseek/install-profile defaults to @deepseek-harness-tui/dsh-tui (~27.5k weekly downloads, ~4x the next, MIT, and it speaks the status contract in §2.3), but accepts any npm name and the resolver never assumes that profile exists. Codeman offers a default; it does not pick a winner.
Permission bypass DSH_PERMISSION_MODE env export, no flag The harness has NO command-line permission option; its sandbox/approval rows read one env var with three presets (read-only / workspace-write / danger-full-access, read off dsh --dump-default-config). This is the one legitimate exception to the CLAUDE_CODE_EFFORT_LEVEL ban: that var hard-locks in-session switching, whereas the harness reads this with ?? as a boot-time DEFAULT, so it stays soft. Exported via tmux setenv, never on the command line. The Run button sends danger-full-access, matching every sibling Run button.
Multi-user clamp branch only-if-sent, clamped to workspace-write, plus an env-var half Omitting the export leaves the harness on workspace-write, which still ASKS, so an absent config is already safe (the codex/antigravity/grok shape, not pi's materialize). Clamping to workspace-write rather than read-only is deliberate: the clamp removes privilege, it must not break a session's ability to edit its own workspace. ⚠️ Unlike every sibling, clamping the CONFIG is only half the gate: the switch is an env var, DSH_* is an allowlisted envOverrides prefix, and applyEnvOverrides() runs AFTER _configureDeepSeek(), so envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'} on the same request would land last and win. clampEnvOverridesForOwner() drops DSH_PERMISSION_MODE and DSH_HOME for a non-granted owner (dropping falls through to the clamped export). DSH_HOME because it aims the launcher at a profile tree whose plugin code runs at BOOT, before any approval row.
hooksAvailableForMode() granularity per SESSION for deepseek, per mode for everything else deepSeekConfig.statusReporting: false disarms the HERDR_* export, and the triple is the only reason a dsh session posts anything, so a mode-only answer would accept until=stop where nothing can send one — the infinite-wait the predicate exists to prevent. Call sites pass sessionHookOptions(session); the default stays permissive so a forgotten one degrades to the old behaviour. ⚠️ Profile conformance stays unknowable at request time (an unrecognized profile is deliberately launchable), so a non-conforming TUI still times out on an explicit stop; the default set keeps idle/exit for that. ⚠️ The predicate is NOT "is this claude": Read My Mind and intent capture read Claude's transcript and were silently widened by this change, so they compare mode === 'claude' directly now.
Profile install spawn own process group, hand-rolled timeout dsh plugin add fans out into package-manager children, and spawn's built-in timeout signals only the direct child: survivors keep the inherited stdio pipes open, close never fires, and the held-open request leaks with no route-level deadline. detached: true + negative-pid SIGTERM→SIGKILL, the same escalation runGit() uses for the same reason, plus a last-resort reap for a grandchild that escaped the group.
Idle detection real hook events via a status shim The standout decision. The TUI already reports its lifecycle to a supervising process through a generic env-gated contract inherited from Herdr: HERDR_ENV=1 + HERDR_BIN_PATH + HERDR_PANE_ID make it run <bin> pane report-agent <id> --state idle|working|blocked … on every state change, exit 0 = delivered. deepseek-status-shim.ts generates a script into the data dir and points HERDR_BIN_PATH at it. So deepseek is the only non-claude mode that passes hooksAvailableForMode() — earned by emitting definitive signals, not granted. An interface implementation, not an impersonation: no real herdr binary is ever executed, and a TUI that ignores the contract simply falls back to output stabilization.
agent_working event new, 157th SSE constant The one hook event with no Claude Code hook behind it. A harness turn cannot run while its own modal approval is on screen, so "started working" proves a dialog was answered in the terminal. Without it a dsh red alert would survive until the next stop — the exact stuck-alert bug the claude path already fixed once, and its pane-capture staleness sweep is Claude-dialog-shaped and cannot help here.
Resolver identity probe THEN version probe Strictest of the family, and not by preference. dsh is not merely a squattable npm name: Debian ships an unrelated dsh (dancer's shell, apt install dsh) which would answer a version probe convincingly and then be handed a spawn line. dsh --help must match DeepSeek Harness first. DEEPSEEK_VERSION_REGEX keeps the prerelease tail (0.1.1-rc.2), since truncating it would report an rc as a release.
Env allowlist DSH_* + DEEPSEEK_* DSH_* covers the launcher's documented inputs (DSH_HOME, DSH_PERMISSION_MODE, DSH_TELEMETRY_MODE, the DSH_TUI_* knobs); DEEPSEEK_* is the vendor namespace holding DEEPSEEK_API_KEY/DEEPSEEK_BASE_URL, same reasoning that admitted XAI_* for grok. ⚠️ Pi's lesson repeats exactly: a dsh settings.yaml can nominate ANY env var as a provider credential (apiKeyEnv), and the allowlist is one GLOBAL list, so admitting those would widen every mode at once. They stay out.
Model NOT a session field The model is a composition entry (agent-default-model) in the profile's config tree, set in ~/.dsh/settings.yaml + cordis.patch.yml. Both create paths deliberately resolve no model for this mode rather than inventing a flag.
Alt-screen strip OUT of isAltScreenStripMode() Third-party fullscreen TUIs with their own scrollback and mouse handling — the opencode case, not the Ink case.
Local echo 'buffer' via the _updateLocalEchoState fallthrough UNMEASURED against a live authenticated session (see §5), same honest gap grok shipped with. The leading TUI's composer supports @ completion and history search, which may make it per-keystroke reactive like codex; if so the fallback is the 'off' branch.
Docker image installs dsh AND a profile Profiles are deliberately NOT seeded from the host: each is a per-profile node_modules tree, host-arch-specific and far too large to copy per container start. Only ~/.dsh/.env, settings.yaml, cordis.patch.yml are seeded (auth + model composition). The profile install rides the useradd layer so the closing chgrp/chmod g=u covers it, which is what keeps it usable under the arbitrary uid the container runs as.
Remote SSH exec "$SHELL" -i -l -c 'dsh' Boots the remote box's default profile; a remote with several needs the per-host commands.deepseek override, since deepSeekConfig does not cross ssh.

3. The web profile

The browser UI is the only interactive surface DeepSeek ships itself, so it gets a shortcut, not a run mode: Run ▸ DeepSeek web UI… starts dsh web --no-open --host 127.0.0.1 --port 3080 --trusted-host <codeman-authority> in an ordinary shell session and opens the URL as an ordinary web tab.

Built entirely from parts that already exist: the server is a shell session (visible, scrollable, killable, dies with its tab) and the UI is a web tab. Nothing new supervises a long-lived HTTP server, because Codeman already does. --trusted-host is load-bearing — dsh fences its /api behind a browser-trust check on the request authority, and a Codeman web tab reaches it through Codeman's own origin via the webview proxy, not directly.

4. Touch points (the checklist)

Backend: types/session.ts (SessionMode + DeepSeekConfig + SessionState), utils/deepseek-cli-resolver.ts (new) + barrel, deepseek-status-shim.ts (new), tmux-manager.ts (buildDeepSeekCommand, dispatch, resume flag, PATH export, truecolor, _configureDeepSeek, availability error, plumbing), session.ts (external-mode gate, label, config plumbing, tmux-required error, attach env), mux-interface.ts, schemas.ts (prefixes, DeepSeekConfigSchema, DeepSeekInstallProfileSchema, both mode enums, remote overrides, cron agentType, agent_working), session-wait-registry.ts (hooksAvailableForMode), hook-event-routes.ts (APPROVAL_RESOLVING_EVENTS), session-routes.ts (clamp + both create paths + resolveDeepSeekLaunchError), system-routes.ts (GET /api/deepseek/status, POST /api/deepseek/install-profile), server.ts (availability inject + mux restore), sse-events.ts, docker-hosts.ts, remote-hosts.ts, config/dependency-registry.ts, response-viewer-transcript.ts, cron/cron-service.ts (comment), tui/tui-client.ts + tui-app.ts.

Frontend: index.html (welcome button, run-mode entry, install affordance, web-UI shortcut, cron option, clone Brain option), session-ui.js (runDeepSeek(), runDeepSeekWeb(), installDeepSeekProfile(), dispatch, availability, "Run DS" label, external-CLI gates), app.js (label, ds tab badge, kill-menu, SSE map), settings-ui.js (welcome gate + _onHookAgentWorking), constants.js, mobile-overview.js, home-sessions.js, panels-ui.js, i18n.js, terminal-ui.js, styles.css + mobile.css (brand-indigo identity; the non-og skin block and the mobile !important pair are both load-bearing).

Meta: docker/agent.Dockerfile, install.sh, package.json keyword, skills/codeman/reference/*, CLAUDE.md, architecture-invariants.md.

Tests: test/deepseek-mode.test.ts + test/deepseek-cli-resolver.test.ts (new); run-mode-ui, render-index-html, mobile-overview, agent-skill-mode-lists (extended).

5. Verification performed

See the summary at the end of the implementing session for the live run. In short: the CI gate green; the resolver, profile inventory, spawn-line and clamp behaviour covered by 31 new unit tests; and an isolated instance used to exercise GET /api/deepseek/status and a real session against the live dsh install.

Not verified (honest gaps):

  • The local-echo 'buffer' policy against the TUI's real composer (§2). If it turns out per-keystroke reactive like codex's, flip it to the 'off' branch; teaching PredictiveEchoAddon its composer row is the larger follow-up.
  • Scrollback/repaint behaviour of a third-party fullscreen TUI under the narrow strip during a long session.
  • A Docker case with mode: 'deepseek' (needs a --no-cache agent-image rebuild — see the --no-cache rule in CLAUDE.md).
  • A remote-SSH deepseek case.
  • The web-UI shortcut end to end through the webview proxy, in particular whether --trusted-host <codeman-authority> is the right authority for dsh's /api fence in every deployment shape (loopback, tailscale, tunnel).

6. Follow-ups

  • Response viewer: read ~/.dsh/sessions/** (JSONL) the way codex rollouts are read back. Highest-value follow-up, and very achievable.
  • headless as an execution backend for Codeman's own AI checks (ai-idle-checker, ai-plan-checker), today Claude-only.
  • Profile/model picker in Session Options, reading GET /api/deepseek/status .profiles.
  • --patch overlays per session, which is the harness-native way to change agent composition without touching the user's profile.
  • Measure the local-echo policy and pin the result the way pi did.