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
+26 -2
View File
@@ -52,6 +52,7 @@ import {
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type DeepSeekConfig,
type SessionRemote,
type SessionDocker,
} from './types.js';
@@ -178,7 +179,8 @@ export function isExternalCliMode(mode: SessionMode): boolean {
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok'
mode === 'grok' ||
mode === 'deepseek'
);
}
@@ -196,6 +198,8 @@ function getModeLabel(mode: SessionMode): string {
return 'Pi';
case 'grok':
return 'Grok';
case 'deepseek':
return 'DeepSeek';
case 'shell':
return 'Shell';
case 'claude':
@@ -521,6 +525,9 @@ export class Session extends EventEmitter {
private _piConfig: PiConfig | undefined;
// Grok configuration (only for mode === 'grok')
private _grokConfig: GrokConfig | undefined;
// DeepSeek Harness configuration (only for mode === 'deepseek')
private _deepSeekConfig: DeepSeekConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
@@ -618,6 +625,8 @@ export class Session extends EventEmitter {
piConfig?: PiConfig;
/** Grok configuration (only for mode === 'grok') */
grokConfig?: GrokConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
@@ -727,6 +736,11 @@ export class Session extends EventEmitter {
this._piConfig = config.piConfig;
}
// Apply DeepSeek Harness configuration
if (config.deepSeekConfig) {
this._deepSeekConfig = config.deepSeekConfig;
}
// Apply Grok configuration
if (config.grokConfig) {
this._grokConfig = config.grokConfig;
@@ -1325,6 +1339,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
@@ -1501,7 +1516,8 @@ export class Session extends EventEmitter {
this.mode === 'gemini' ||
this.mode === 'antigravity' ||
this.mode === 'pi' ||
this.mode === 'grok'
this.mode === 'grok' ||
this.mode === 'deepseek'
),
})
);
@@ -1572,6 +1588,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1831,6 +1848,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1924,6 +1942,12 @@ export class Session extends EventEmitter {
if (this.mode === 'grok') {
throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.');
}
// DeepSeek sessions require tmux for DEEPSEEK_API_KEY / DSH_PERMISSION_MODE
// injection via setenv — and for the HERDR_* status-bridge triple, without
// which the mode silently loses its definitive idle/blocked signals.
if (this.mode === 'deepseek') {
throw new Error('DeepSeek Harness sessions require tmux. Direct PTY fallback is not supported.');
}
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab