mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
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:
+83
-4
@@ -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']),
|
||||
|
||||
Reference in New Issue
Block a user