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
+11 -2
View File
@@ -24,8 +24,17 @@ const APPROVAL_KIND_BY_EVENT: Record<string, ApprovalKind> = {
idle_prompt: 'idle',
};
/** Hook events that close a session's pending item without an inbox answer. */
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response']);
/**
* Hook events that close a session's pending item without an inbox answer.
*
* `agent_working` is here because it is the DeepSeek status bridge's report that
* a turn STARTED, and a harness turn cannot be running while one of its own
* modal approvals is on screen — so the agent moving means the dialog was
* answered, in the terminal, by the user. That is the same conclusion the claude
* path reaches through pane capture, which cannot help here because its frame
* parser is Claude-dialog-shaped.
*/
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response', 'agent_working']);
export function registerHookEventRoutes(
app: FastifyInstance,
+93 -6
View File
@@ -25,6 +25,7 @@ import {
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type DeepSeekConfig,
} from '../../types.js';
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
@@ -337,6 +338,14 @@ export function _resetPasteRateBuckets(): void {
* Grok is like Codex/Antigravity: the bypass switch is `alwaysApprove`
* (`--always-approve`), and an ABSENT config already spawns in grok's own
* ask-mode default, so only a sent config needs the flag forced off.
*
* DeepSeek joins the same only-if-sent branch, but its switch is not a flag: the
* harness has no command-line permission option, and its sandbox/approval rows
* read `DSH_PERMISSION_MODE`. Omitting that export leaves the harness on its own
* `workspace-write` preset, which still asks, so an absent config is already
* safe; a sent one is forced down to `workspace-write` rather than to
* `read-only`, because the clamp exists to remove PRIVILEGE, not to break a
* session's ability to edit its own workspace.
*/
async function clampExternalCliBypassForOwner(
owner: string | undefined,
@@ -344,16 +353,18 @@ async function clampExternalCliBypassForOwner(
geminiConfig: GeminiConfig | undefined,
antigravityConfig: AntigravityConfig | undefined,
piConfig: PiConfig | undefined,
grokConfig: GrokConfig | undefined
grokConfig: GrokConfig | undefined,
deepSeekConfig: DeepSeekConfig | undefined
): Promise<{
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
antigravityConfig: AntigravityConfig | undefined;
piConfig: PiConfig | undefined;
grokConfig: GrokConfig | undefined;
deepSeekConfig: DeepSeekConfig | undefined;
}> {
const granted = await canUsernameRunPrivilegedCommands(owner);
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig };
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig };
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default)
// and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
@@ -364,18 +375,63 @@ async function clampExternalCliBypassForOwner(
: antigravityConfig;
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig;
const clampedDeepSeek = deepSeekConfig
? { ...deepSeekConfig, permissionMode: 'workspace-write' as const }
: deepSeekConfig;
return {
codexConfig: clampedCodex,
geminiConfig: clampedGemini,
antigravityConfig: clampedAntigravity,
piConfig: clampedPi,
grokConfig: clampedGrok,
deepSeekConfig: clampedDeepSeek,
};
}
/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */
export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
/**
* Why a DeepSeek session cannot start, or null when it can.
*
* Availability for this mode is TWO questions, not one, because `dsh` is a
* profile launcher rather than an agent: the binary must resolve (and prove it
* is the harness and not Debian's dancer's shell), AND a profile that can occupy
* a pane must exist. Reporting only the first would let the Run button spawn a
* pane that dies instantly, which is the single most confusing failure this mode
* can produce, so each half gets its own actionable message.
*
* A profile named EXPLICITLY is checked on both counts: existence, and whether
* it is pane-capable — `web` serves a browser UI and `headless` answers one task
* and exits, so both would present as "the tab immediately died".
*/
async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise<string | null> {
const { isDeepSeekAvailable, getDeepSeekNotFoundMessage, listDeepSeekProfiles, resolveDefaultDeepSeekProfile } =
await import('../../utils/deepseek-cli-resolver.js');
if (!isDeepSeekAvailable()) return getDeepSeekNotFoundMessage();
const profiles = listDeepSeekProfiles();
if (requestedProfile) {
const match = profiles.find((p) => p.name === requestedProfile);
if (!match) {
return `DeepSeek Harness profile "${requestedProfile}" does not exist. Create it with: dsh plugin --profile ${requestedProfile} add <package>`;
}
if (match.kind === 'web' || match.kind === 'headless') {
return `DeepSeek Harness profile "${requestedProfile}" is a ${match.kind} profile and cannot run in a terminal session. Pick an interactive profile, or open the web profile as a Codeman web tab.`;
}
return null;
}
if (!resolveDefaultDeepSeekProfile()) {
return (
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only the web and headless ' +
'profiles, so the terminal agent comes from a plugin — install one with: ' +
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
);
}
return null;
}
// ═══════════════════════════════════════════════════════════════
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
// ═══════════════════════════════════════════════════════════════
@@ -770,6 +826,7 @@ export function registerSessionRoutes(
body.mode !== 'antigravity' &&
body.mode !== 'pi' &&
body.mode !== 'grok' &&
body.mode !== 'deepseek' &&
body.envOverrides &&
Object.keys(body.envOverrides).length > 0 &&
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
@@ -859,6 +916,10 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
}
}
if (body.mode === 'deepseek') {
const err = await resolveDeepSeekLaunchError(body.deepSeekConfig?.profile);
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
}
if (body.mode === 'grok') {
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
if (!isGrokAvailable()) {
@@ -912,7 +973,10 @@ export function registerSessionRoutes(
? body.piConfig?.model
: mode === 'grok'
? body.grokConfig?.model
: mode !== 'shell'
: // DeepSeek's model is a composition entry in the profile's config
// tree, not a session flag, so there is deliberately nothing to
// read here (see docs/deepseek-integration.md).
mode !== 'shell' && mode !== 'deepseek'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
@@ -925,13 +989,15 @@ export function registerSessionRoutes(
antigravityConfig: gatedAntigravityConfig,
piConfig: gatedPiConfig,
grokConfig: gatedGrokConfig,
deepSeekConfig: gatedDeepSeekConfig,
} = await clampExternalCliBypassForOwner(
owner,
body.codexConfig,
body.geminiConfig,
body.antigravityConfig,
body.piConfig,
body.grokConfig
body.grokConfig,
body.deepSeekConfig
);
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
@@ -950,6 +1016,7 @@ export function registerSessionRoutes(
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
effort: body.effort,
@@ -2717,6 +2784,7 @@ export function registerSessionRoutes(
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
envOverrides,
effort,
parentSessionId,
@@ -2766,6 +2834,7 @@ export function registerSessionRoutes(
antigravityConfig ||
piConfig ||
grokConfig ||
deepSeekConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2909,6 +2978,12 @@ export function registerSessionRoutes(
}
}
// Check DeepSeek Harness availability if requested (binary AND a pane-capable profile).
if (mode === 'deepseek') {
const err = await resolveDeepSeekLaunchError(deepSeekConfig?.profile);
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
@@ -3036,6 +3111,7 @@ export function registerSessionRoutes(
mode !== 'antigravity' &&
mode !== 'pi' &&
mode !== 'grok' &&
mode !== 'deepseek' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
@@ -3060,7 +3136,8 @@ export function registerSessionRoutes(
? piConfig?.model
: mode === 'grok'
? grokConfig?.model
: mode !== 'shell'
: // DeepSeek's model lives in the profile's config tree, not here.
mode !== 'shell' && mode !== 'deepseek'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
@@ -3072,7 +3149,16 @@ export function registerSessionRoutes(
antigravityConfig: qsGatedAntigravityConfig,
piConfig: qsGatedPiConfig,
grokConfig: qsGatedGrokConfig,
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig);
deepSeekConfig: qsGatedDeepSeekConfig,
} = await clampExternalCliBypassForOwner(
owner,
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig
);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir: resolvedCasePath,
@@ -3091,6 +3177,7 @@ export function registerSessionRoutes(
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
envOverrides,
effort,
remote,
+122 -1
View File
@@ -16,7 +16,7 @@ import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser } from '../../user-store.js';
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
import {
ConfigUpdateSchema,
@@ -26,6 +26,7 @@ import {
SubagentWindowStatesSchema,
SubagentParentMapSchema,
RevokeSessionSchema,
DeepSeekInstallProfileSchema,
} from '../schemas.js';
import { subagentWatcher } from '../../subagent-watcher.js';
import { imageWatcher } from '../../image-watcher.js';
@@ -48,6 +49,7 @@ import {
} from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
import { getRepositoryStatus } from '../repo-status.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
@@ -55,6 +57,20 @@ import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';
/**
* Defaults for `POST /api/deepseek/install-profile`.
*
* The package is the community terminal front door with by far the widest use
* (~27.5k weekly downloads at time of writing, roughly 4x the next), MIT, and
* the one whose supervisor-reporting contract Codeman's status bridge speaks.
* It is a DEFAULT, not a hardcoding: the endpoint accepts any npm name, and the
* resolver never assumes this profile exists.
*/
const DEEPSEEK_DEFAULT_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui';
const DEEPSEEK_DEFAULT_PROFILE = 'dsh-tui';
/** A plugin install compiles and links a dependency tree; npm-scale, not curl-scale. */
const DEEPSEEK_INSTALL_TIMEOUT_MS = 300_000;
// Maximum screenshot upload size (10MB)
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
// Screenshots directory
@@ -461,6 +477,111 @@ export function registerSystemRoutes(
};
});
// ========== DeepSeek Harness ==========
// The widest of the per-CLI status shapes, because this mode has the widest
// failure surface. Three fields beyond the sibling `available`/`path`:
//
// - `version`, like pi/grok, so a misresolution is diagnosable — and here the
// stakes are higher, since `dsh` is also an existing Debian program
// (dancer's shell) rather than merely a squattable npm name.
// - `profiles`, because `dsh` is a LAUNCHER: a perfectly installed binary with
// no pane-capable profile cannot start a session, and the UI has to be able
// to say which of the two halves is missing.
// - `runnable` + `defaultProfile`, the answer the Run button actually needs,
// so no caller has to re-derive it from the parts and get it subtly wrong.
app.get('/api/deepseek/status', async () => {
const {
isDeepSeekAvailable,
isDeepSeekRunnable,
resolveDeepSeekDir,
getDeepSeekCliVersion,
listDeepSeekProfiles,
resolveDefaultDeepSeekProfile,
resolveDshHome,
} = await import('../../utils/deepseek-cli-resolver.js');
return {
available: isDeepSeekAvailable(),
runnable: isDeepSeekRunnable(),
path: resolveDeepSeekDir(),
version: getDeepSeekCliVersion(),
dshHome: resolveDshHome(),
defaultProfile: resolveDefaultDeepSeekProfile(),
profiles: listDeepSeekProfiles(),
};
});
// Bootstrap an interactive profile so the mode becomes usable.
//
// This exists because DeepSeek ships NO terminal front door: `dsh` on its own
// can only serve a browser UI or answer one headless task, and the agent a
// Codeman pane runs is always a plugin the user installed. Without this the
// mode's first-run experience is a dead Run button and a paragraph of shell
// instructions.
//
// It is the only endpoint in Codeman that installs third-party code, so it is
// fenced accordingly:
// - the privileged grant is required in multi-user mode (same bar as a
// `shell` session, which can already do strictly more);
// - 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 through the resolved `dsh`, never a shell
// string, so even a specifier that slipped the regex could not become a
// second command;
// - the request is held open with a bounded timeout, mirroring the
// synchronous-clone precedent in `POST /api/cases/clone` rather than
// introducing a job store for a once-per-install action.
app.post('/api/deepseek/install-profile', async (req) => {
const body = parseBody(DeepSeekInstallProfileSchema, req.body);
if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'Installing a DeepSeek Harness profile requires the can-bypass-permissions grant'
);
}
const { resolveDeepSeekDir, getDeepSeekNotFoundMessage } = await import('../../utils/deepseek-cli-resolver.js');
const dir = resolveDeepSeekDir();
if (!dir) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getDeepSeekNotFoundMessage());
const profile = body.profile || DEEPSEEK_DEFAULT_PROFILE;
const pkg = body.package || DEEPSEEK_DEFAULT_TUI_PACKAGE;
const result = await new Promise<{ code: number | null; output: string }>((resolve) => {
const child = spawn(join(dir, 'dsh'), ['plugin', '--profile', profile, 'add', pkg], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout: DEEPSEEK_INSTALL_TIMEOUT_MS,
// dsh bundles its own package manager, so no system pnpm is required —
// but it still needs a HOME to resolve $DSH_HOME against.
env: process.env,
});
let output = '';
const capture = (chunk: Buffer) => {
// Bounded: a package manager can emit megabytes of progress.
if (output.length < 16_384) output += chunk.toString('utf-8');
};
child.stdout?.on('data', capture);
child.stderr?.on('data', capture);
child.on('error', (err) => resolve({ code: null, output: `${output}\n${err.message}` }));
child.on('close', (code) => resolve({ code, output }));
});
if (result.code !== 0) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Installing ${pkg} into profile "${profile}" failed: ${result.output.slice(-1000).trim() || 'no output'}`
);
}
const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } =
await import('../../utils/deepseek-cli-resolver.js');
return {
profile,
package: pkg,
runnable: isDeepSeekRunnable(),
defaultProfile: resolveDefaultDeepSeekProfile(),
profiles: listDeepSeekProfiles(),
};
});
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════