mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59: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:
+146
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user