feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode

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>
This commit is contained in:
Codeman maintainer
2026-08-24 03:37:56 +02:00
parent 9cfd8e8989
commit 4cda150493
48 changed files with 2489 additions and 66 deletions
+146 -2
View File
@@ -53,6 +53,7 @@ import {
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type DeepSeekConfig,
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
@@ -95,6 +96,9 @@ import {
getPiNotFoundMessage,
resolveGrokDir,
getGrokNotFoundMessage,
resolveDeepSeekDir,
getDeepSeekNotFoundMessage,
resolveDefaultDeepSeekProfile,
resolveLocalShell,
loginShellArgs,
} from './utils/index.js';
@@ -119,6 +123,7 @@ import {
// ============================================================================
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js';
/** How long a cached process snapshot stays usable. */
const PROC_SNAPSHOT_TTL_MS = 2000;
@@ -846,6 +851,51 @@ function buildGrokCommand(config?: GrokConfig): string {
return parts.join(' ');
}
/**
* Build the DeepSeek Harness (`dsh`) command with appropriate flags.
*
* Unlike every sibling builder, the interesting decision here is not a flag but
* WHICH PROFILE to boot: `dsh` is a launcher over `$DSH_HOME/profiles/<name>`,
* and DeepSeek ships no interactive terminal profile of its own, so the agent a
* pane runs is always one the user installed. An absent `profile` resolves to
* the first pane-capable profile on the box; when there is none we still emit a
* bare `dsh --profile <default>` rather than inventing a name, because the
* availability gate in createSession() has already refused the spawn by then and
* this path only runs for a session that passed it.
*
* There is deliberately NO permission flag: the harness has none. The sandbox
* and approval rows read `DSH_PERMISSION_MODE`, exported through `tmux setenv`
* in buildEnvExports() so it never lands on this command line.
*
* Like the sibling builders, every user value is regex-allowlisted and silently
* DROPPED on failure: the result is interpolated into a `bash -c "..."` string.
*/
function buildDeepSeekCommand(config?: DeepSeekConfig): string {
const parts = ['dsh'];
// A profile name is a single path segment: it is both interpolated into the
// shell line and joined into a filesystem path.
const requested = config?.profile;
const safeProfile =
requested && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(requested)
? requested
: (resolveDefaultDeepSeekProfile() ?? undefined);
if (safeProfile) parts.push('--profile', safeProfile);
// The launcher forwards everything after its own flags to the profile's app,
// which is where `--resume` is understood. An explicit id wins over the
// most-recent-session form, mirroring the sibling builders.
const safeSessionId =
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeSessionId) {
parts.push('--resume', safeSessionId);
} else if (config?.resumeSession) {
parts.push('--resume');
}
return parts.join(' ');
}
/**
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
@@ -890,6 +940,7 @@ export function buildSpawnCommand(options: {
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
@@ -942,6 +993,9 @@ export function buildSpawnCommand(options: {
if (options.mode === 'grok') {
return buildGrokCommand(options.grokConfig);
}
if (options.mode === 'deepseek') {
return buildDeepSeekCommand(options.deepSeekConfig);
}
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
// so a `$SHELL` here is expanded by the SERVER process's shell against the
@@ -1159,6 +1213,8 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
return `${modeCommand} --session ${resumeId}`;
case 'grok':
return `${modeCommand} --resume ${resumeId}`;
case 'deepseek':
return `${modeCommand} --resume ${resumeId}`;
default:
return modeCommand; // shell / opencode: no resume
}
@@ -1749,10 +1805,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
mode === 'codex' ||
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek'
? 'export COLORTERM=truecolor'
: 'unset COLORTERM',
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
...(mode === 'codex' ||
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek'
? ['unset NO_COLOR']
: []),
// Stamp each Codex pane with a unique originator so the response-viewer
@@ -1853,6 +1919,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dir = resolveGrokDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'deepseek') {
const dir = resolveDeepSeekDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
@@ -1883,6 +1953,65 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
setGeminiEnvVars(this.tmux(), muxName);
}
/**
* Configure DeepSeek Harness environment on a tmux session.
*
* Two independent things, both via `tmux setenv` so they are inherited by the
* pane without appearing in `ps`:
*
* 1. `DSH_PERMISSION_MODE` — the harness's only permission input. Exported
* ONLY when the caller sent one, so an absent config lands on the harness's
* own `workspace-write` default (which asks) rather than on ours. That
* "only if sent" shape is what the multi-user clamp relies on.
* 2. The `HERDR_*` triple — the supervisor contract the terminal front door
* uses to report idle/working/blocked. Pointing `HERDR_BIN_PATH` at our own
* generated shim is what upgrades this mode from output-stabilization
* guessing to definitive hook events (see deepseek-status-shim.ts). The
* pane id IS the Codeman session id, which is how the shim attributes a
* report without trusting anything the agent could influence.
*
* Also forwards DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from the server env when
* present, matching the codex/gemini precedent for headless auth.
*/
private _configureDeepSeek(muxName: string, sessionId: string, config?: DeepSeekConfig): void {
const tmuxCmd = this.tmux();
const setenv = (key: string, value: string): void => {
const escaped = value.replace(/'/g, "'\\''");
try {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical */
}
};
for (const key of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DSH_HOME']) {
const val = process.env[key];
if (val) setenv(key, val);
}
// Enum-validated at the schema boundary; re-checked here because this value
// reaches a shell line, and a builder must never trust its caller.
if (
config?.permissionMode &&
['read-only', 'workspace-write', 'danger-full-access'].includes(config.permissionMode)
) {
setenv('DSH_PERMISSION_MODE', config.permissionMode);
}
if (config?.statusReporting !== false) {
const shim = ensureDeepSeekStatusShim();
if (shim) {
setenv('HERDR_ENV', '1');
setenv('HERDR_BIN_PATH', shim);
setenv('HERDR_PANE_ID', sessionId);
}
}
}
/**
* Creates a new tmux session wrapping Claude CLI or a shell.
* In test mode: creates an in-memory session only (no real tmux session).
@@ -1903,6 +2032,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
resumeSessionId,
envOverrides,
effort,
@@ -1963,6 +2093,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'pi' && !cliDir) {
throw new Error(getPiNotFoundMessage());
}
if (mode === 'deepseek' && !cliDir) {
throw new Error(getDeepSeekNotFoundMessage());
}
if (mode === 'grok' && !cliDir) {
throw new Error(getGrokNotFoundMessage());
}
@@ -1981,6 +2114,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
resumeSessionId,
effort,
sessionName: name,
@@ -2049,6 +2183,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'gemini') {
this._configureGemini(muxName);
}
// For DeepSeek: permission mode + the Herdr-compatible status bridge.
if (mode === 'deepseek') {
this._configureDeepSeek(muxName, sessionId, deepSeekConfig);
}
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
// so secret values stay off the bash command line. Must run before respawn-pane.
@@ -2206,6 +2344,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
resumeSessionId,
envOverrides,
effort,
@@ -2236,6 +2375,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
resumeSessionId,
effort,
sessionName: name,
@@ -2260,6 +2400,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'gemini') {
this._configureGemini(muxName);
}
// For DeepSeek: permission mode + the Herdr-compatible status bridge.
if (mode === 'deepseek') {
this._configureDeepSeek(muxName, sessionId, deepSeekConfig);
}
// Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides);