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
+83 -4
View File
@@ -132,6 +132,15 @@ const ALLOWED_ENV_PREFIXES = [
'PI_',
'GROK_',
'XAI_',
// DeepSeek Harness: `DSH_*` carries the launcher's own documented inputs
// (DSH_HOME, DSH_PERMISSION_MODE, DSH_TELEMETRY_MODE, and the DSH_TUI_* knobs
// the terminal front door reads); `DEEPSEEK_*` is the vendor namespace holding
// DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL, the same narrow-vendor reasoning that
// admitted XAI_* for grok. Foreign provider keys stay out: a dsh settings.yaml
// can name ANY env var as a provider credential (apiKeyEnv), which is pi's
// 34-provider-key problem in a new shape, and the answer is the same one.
'DSH_',
'DEEPSEEK_',
];
/**
@@ -171,7 +180,7 @@ const safeEnvOverridesSchema = z
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_* keys and CLAUDE_CONFIG_DIR are allowed.',
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_* keys and CLAUDE_CONFIG_DIR are allowed.',
}
);
@@ -336,6 +345,68 @@ const GrokConfigSchema = z
})
.optional();
/**
* Schema for DeepSeek Harness (`dsh`)-specific configuration.
*
* `permissionMode` maps to the `DSH_PERMISSION_MODE` env export, NOT to a flag —
* the harness has no command-line permission switch. An ABSENT config spawns the
* profile under the harness's own `workspace-write` default, which still asks
* for approval, so the multi-user clamp only needs the only-if-sent branch (like
* codex/antigravity/grok).
*
* `profile` is a directory name under `$DSH_HOME/profiles`, so it is constrained
* to a single path SEGMENT: no separators, no dots-only names. It is interpolated
* into the `bash -c "…"` spawn line and joined into a filesystem path, and this
* regex is what keeps both safe.
*/
const DeepSeekConfigSchema = z
.object({
profile: z
.string()
.min(1)
.max(64)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
.optional(),
permissionMode: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(),
resumeSession: z.boolean().optional(),
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._-]+$/)
.optional(),
statusReporting: z.boolean().optional(),
})
.optional();
/**
* Body of POST /api/deepseek/install-profile.
*
* `package` is a package SPECIFIER handed to `dsh plugin … add`, which runs a
* real package-manager install, so it is the security-relevant field. Two things
* contain it: this regex (an npm name, optionally scoped, optionally with an
* `@version` tail, and NOTHING else — no path, no URL, no git spec, no leading
* dash that could be read as a flag), and the route, which spawns an argv ARRAY
* with no shell. The route additionally requires the privileged grant in
* multi-user mode: installing a plugin is arbitrary code execution on the host,
* the same bar as a `shell` session.
*/
export const DeepSeekInstallProfileSchema = z
.object({
profile: z
.string()
.min(1)
.max(64)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
.optional(),
package: z
.string()
.min(1)
.max(214)
.regex(/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[a-zA-Z0-9][a-zA-Z0-9._-]*)?$/)
.optional(),
})
.strict();
/**
* The session that spawned the one being created — pure UI decoration, drawn as a
* lineage line between the two tabs. Accepted here and, equivalently, as the
@@ -349,7 +420,7 @@ const parentSessionIdSchema = z.string().max(100).optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(),
name: z.string().max(100).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema,
@@ -366,6 +437,7 @@ export const CreateSessionSchema = z.object({
antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
deepSeekConfig: DeepSeekConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
@@ -502,6 +574,7 @@ const RemoteCommandOverridesSchema = z
antigravity: z.string().min(1).max(300).optional(),
pi: z.string().min(1).max(300).optional(),
grok: z.string().min(1).max(300).optional(),
deepseek: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
@@ -776,13 +849,14 @@ export const QuickStartSchema = z.object({
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
deepSeekConfig: DeepSeekConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
@@ -804,6 +878,11 @@ export const HookEventSchema = z.object({
'stop',
'teammate_idle',
'task_completed',
// A turn STARTED. Unlike the others this one has no Claude Code hook behind
// it: it is reported by the DeepSeek Harness status shim, and exists so a
// dialog answered in the terminal resolves its Approvals Inbox item at once
// instead of lingering red until the next `stop`.
'agent_working',
]),
sessionId: z.string().min(1),
data: z.record(z.string(), z.unknown()).nullable().optional(),
@@ -1310,7 +1389,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),