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
+10 -5
View File
@@ -240,6 +240,7 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'],
[SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'],
[SSE_EVENTS.HOOK_STOP, '_onHookStop'],
[SSE_EVENTS.HOOK_AGENT_WORKING, '_onHookAgentWorking'],
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
@@ -2252,9 +2253,11 @@ class CodemanApp {
? 'Pi'
: mode === 'grok'
? 'Grok'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
: mode === 'deepseek'
? 'DeepSeek'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
}
async toggleResponseViewer() {
@@ -4805,7 +4808,7 @@ class CodemanApp {
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : ''}
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : ''}
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span>
@@ -6238,7 +6241,9 @@ class CodemanApp {
? 'Kill Tmux & Pi'
: session.mode === 'grok'
? 'Kill Tmux & Grok'
: 'Kill Tmux & Claude Code';
: session.mode === 'deepseek'
? 'Kill Tmux & DeepSeek'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
+7
View File
@@ -54,6 +54,12 @@ const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notificati
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
/**
* Port the DeepSeek Harness browser UI is started on by the run-menu shortcut.
* dsh's own default, so a hand-started `dsh web` and the shortcut land on the
* same place and share one saved tab.
*/
const DEEPSEEK_WEB_PORT = 3080;
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
const TERMINAL_TAIL_SIZE = 1024 * 1024; // 1MB tail for initial load (more scrollback on tab switch)
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
@@ -949,6 +955,7 @@ const SSE_EVENTS = {
HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete',
HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response',
HOOK_STOP: 'hook:stop',
HOOK_AGENT_WORKING: 'hook:agent_working',
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
HOOK_TASK_COMPLETED: 'hook:task_completed',
+1
View File
@@ -79,6 +79,7 @@ const HOME_SESSIONS_MODE_BADGE = {
antigravity: 'ag',
pi: 'pi',
grok: 'gk',
deepseek: 'ds',
};
Object.assign(CodemanApp.prototype, {
+1
View File
@@ -110,6 +110,7 @@
'Run Antigravity': '运行 Antigravity',
'Run Pi': '运行 Pi',
'Run Grok': '运行 Grok',
'Run DeepSeek': '运行 DeepSeek',
'Run Shell': '运行 Shell',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
+23 -1
View File
@@ -448,6 +448,10 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Grok
</button>
<button class="welcome-btn welcome-btn-deepseek" id="welcomeDeepSeekBtn" style="display: none;" onclick="app.setRunMode('deepseek'); app.runDeepSeek()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run DeepSeek
</button>
</div>
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
@@ -630,6 +634,15 @@
<button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')">
<span class="run-mode-dot grok"></span>Grok
</button>
<button class="run-mode-option" data-mode="deepseek" onclick="app.setRunMode('deepseek')">
<span class="run-mode-dot deepseek"></span>DeepSeek
</button>
<!-- Shown only when `dsh` is installed but no pane-capable profile is:
DeepSeek ships no terminal front door, so the fix is an install,
not a greyed-out entry the user cannot act on. -->
<button class="run-mode-option run-mode-option-install" data-action="deepseek-install" id="runModeDeepSeekInstall" style="display: none;" onclick="app.installDeepSeekProfile()">
<span class="run-mode-dot deepseek"></span>DeepSeek — add a terminal profile…
</button>
<div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<span class="run-mode-dot shell"></span>Terminal / Shell
@@ -642,6 +655,13 @@
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
<span class="run-mode-dot web"></span>Add URL&hellip;
</button>
<!-- The DeepSeek Harness browser UI is the vendor's OWN interactive
surface (the terminal one is third-party), so it gets a shortcut:
this starts `dsh web` in a shell session and opens it as a tab.
Shown only when dsh is installed. -->
<button class="run-mode-option run-mode-option--web" id="runModeDeepSeekWeb" style="display: none;" onclick="app.runDeepSeekWeb()">
<span class="run-mode-dot deepseek"></span>DeepSeek web UI&hellip;
</button>
<div class="run-mode-sep"></div>
<div class="run-mode-header">Recent Sessions</div>
<div class="run-mode-history" id="runModeHistory"></div>
@@ -908,6 +928,7 @@
<option value="antigravity">Antigravity</option>
<option value="pi">Pi</option>
<option value="grok">Grok</option>
<option value="deepseek">DeepSeek</option>
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -2676,6 +2697,7 @@
<option value="antigravity" data-cli="antigravity">Antigravity</option>
<option value="pi" data-cli="pi">Pi</option>
<option value="grok" data-cli="grok">Grok</option>
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
<option value="shell">Shell (no agent)</option>
</select>
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
@@ -2816,7 +2838,7 @@
<div class="form-row">
<label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok + tmux.</span>
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.</span>
</div>
<div class="form-row">
<label>Network</label>
+1
View File
@@ -55,6 +55,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
{ mode: 'pi', label: 'Pi', short: 'Pi' },
{ mode: 'grok', label: 'Grok', short: 'Grok' },
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
];
+23
View File
@@ -986,6 +986,23 @@ html.mobile-init .file-browser-panel {
border-color: rgba(212, 212, 216, 0.5) !important;
}
/* DeepSeek mode colors on mobile. Same `!important` rationale as the pi and
grok blocks above: styles.css nests its skin rules inside
`html:not([data-skin="og"])`, so a bare `.btn-toolbar` rule there outranks a
`.btn-toolbar.btn-x` rule here regardless of load order. */
.btn-toolbar.btn-run.mode-deepseek,
.btn-toolbar.btn-run-gear.mode-deepseek {
background: #16225f !important;
border-color: rgba(124, 147, 255, 0.35) !important;
color: #eef2ff !important;
}
.btn-toolbar.btn-run.mode-deepseek:active,
.btn-toolbar.btn-run-gear.mode-deepseek:active {
background: #3350e6 !important;
border-color: rgba(150, 170, 255, 0.55) !important;
}
/* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu {
bottom: 100%;
@@ -3075,6 +3092,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-deepseek, .btn-toolbar.btn-run-gear.mode-deepseek) {
background: linear-gradient(135deg, #2740c4, #4d6bfe);
border-color: #1b2a8f;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
border-left-color: var(--control-border-hover) !important;
}
+1 -1
View File
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok' };
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
+198 -6
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok),
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok/DeepSeek),
* session options modal (per-session settings, color picker, rename),
* session options tabs (Ralph config tab), case settings (CRUD, links),
* create case modal, and mobile case picker.
@@ -406,6 +406,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'grok') {
return await this.runGrok();
}
if (mode === 'deepseek') {
return await this.runDeepSeek();
}
if (mode === 'shell') {
return await this.runShell();
}
@@ -471,10 +474,121 @@ Object.assign(CodemanApp.prototype, {
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
*/
_refreshRunModeAvailability(menu) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) {
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
}
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
// perfectly installed while no pane-capable profile exists, because DeepSeek
// ships no terminal front door. In that state the honest offer is "add one",
// not a hidden entry with no explanation anywhere.
const avail = window.__codemanCliAvailable || {};
const dsInstall = menu.querySelector('#runModeDeepSeekInstall');
if (dsInstall) {
dsInstall.style.display = !avail.deepseek && avail.deepseekBinary ? 'flex' : 'none';
}
// The web UI needs only the BINARY: it is the one interactive surface
// DeepSeek ships itself, so it works on a box with no terminal profile at
// all (and is the honest thing to offer there).
const dsWeb = menu.querySelector('#runModeDeepSeekWeb');
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
},
/**
* Start the DeepSeek Harness browser UI and open it as a Codeman web tab.
*
* Deliberately built from parts that already exist rather than a new process
* manager: the server runs in an ordinary SHELL session, so it is visible,
* scrollable, killable and dies with its tab like anything else, and the UI
* itself is an ordinary web tab. Nothing here needs to know how to supervise a
* long-lived HTTP server, because Codeman already does.
*
* `--trusted-host` is the load-bearing flag: dsh fences its `/api` behind a
* browser-trust check on the request authority, and a Codeman web tab reaches
* it through Codeman's own origin via the webview proxy, not directly. Without
* passing Codeman's authority the page renders and every API call fails.
*/
async runDeepSeekWeb() {
document.getElementById('runModeMenu')?.classList.remove('active');
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const port = DEEPSEEK_WEB_PORT;
const url = `http://127.0.0.1:${port}`;
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting the DeepSeek web UI in ${caseName}...`);
try {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'shell',
sessionName: `dsh-web-${caseName}`,
}),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start the shell session');
const sessionId = data.data.sessionId;
await this._ensureCreatedSessionVisible(sessionId, data.data.session);
// The shell needs a moment to reach its prompt before it will accept a
// command; the same settle the other shell-driven flows use.
await new Promise((r) => setTimeout(r, 1200));
const cmd = `dsh web --no-open --host 127.0.0.1 --port ${port} --trusted-host ${location.host}`;
await fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: `${cmd}\r` }),
});
// Reuse a saved tab for the same URL rather than stacking duplicates every
// time the server is restarted.
let webview = [...(this.webviews?.values() || [])].find((w) => w.url === url);
if (!webview) {
const wvRes = await fetch('/api/webviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'DeepSeek Harness', url, icon: '🐳' }),
});
const wvData = await wvRes.json();
if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab');
webview = wvData.data.webview || wvData.data;
await this.loadWebviews?.();
}
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} — opening it as a tab.`);
if (webview?.id) await this.openWebview(webview.id);
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
/**
* Install a DeepSeek Harness terminal profile from the run menu.
*
* Held open for as long as the package manager takes (the endpoint bounds it),
* so the button reports progress rather than appearing to do nothing. On
* success the availability map is patched in place, which is what makes the
* real DeepSeek entry appear without a reload.
*/
async installDeepSeekProfile() {
const label = 'Installing a DeepSeek terminal profile (this can take a minute)...';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(label);
try {
const res = await fetch('/api/deepseek/install-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to install the profile');
window.__codemanCliAvailable = { ...(window.__codemanCliAvailable || {}), deepseek: !!data.data.runnable };
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Installed ${data.data.package} into profile "${data.data.profile}".`);
this.showToast?.(`DeepSeek profile "${data.data.profile}" installed`, 'success');
const menu = document.getElementById('runModeMenu');
if (menu) this._refreshRunModeAvailability(menu);
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
async _loadRunModeHistory() {
@@ -568,7 +682,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'shell' ? 'Run SH' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'shell' ? 'Run SH' : 'Run';
}
},
@@ -1341,6 +1455,84 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Launch a DeepSeek Harness (`dsh`) session.
*
* Sends `permissionMode: 'danger-full-access'` for the same reason every
* sibling Run button sends its bypass switch: Codeman sessions exist for
* autonomous work. The harness has no bypass FLAG, so this rides the
* `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it
* back down to `workspace-write` for non-granted owners server-side.
*
* `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from
* output-stabilization guessing to definitive idle/blocked hook events.
*
* The two-part availability check is deliberate. `dsh` being installed is not
* enough — DeepSeek ships no terminal front door, so a box can have a perfect
* binary and nothing a pane can run. Reporting that precisely, with the exact
* command that fixes it, is the difference between "the Run button is broken"
* and a 30-second fix.
*/
async runDeepSeek() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run dsh on the OTHER side: skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting DeepSeek session in ${caseName}...`);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/deepseek/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'
);
return;
}
if (!status.runnable) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
);
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'deepseek',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
deepSeekConfig: { permissionMode: 'danger-full-access' },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start DeepSeek');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
@@ -1406,7 +1598,7 @@ Object.assign(CodemanApp.prototype, {
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok';
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek';
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
@@ -1436,7 +1628,7 @@ Object.assign(CodemanApp.prototype, {
}
// Hide Claude-specific options for external CLI sessions
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok';
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
@@ -3217,7 +3409,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
},
set(mode) {
this._runMode =
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'claude'
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'claude'
? mode
: 'claude';
},
+14
View File
@@ -67,6 +67,19 @@ Object.assign(CodemanApp.prototype, {
this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding');
},
_onHookAgentWorking(data) {
// The agent started a turn, so whatever it was blocked on is gone. Reported
// by the DeepSeek status bridge; a harness turn cannot run while one of its
// own modal approvals is on screen, so this means the dialog was answered in
// the terminal. Same clearing as _onHookElicitationComplete, and notably NOT
// a notification: a turn STARTING is not news.
if (data.sessionId) {
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
this.clearPendingHooks(data.sessionId, 'permission_prompt');
this.clearPendingHooks(data.sessionId, 'idle_prompt');
}
},
_onHookTeammateIdle(data) {
const session = this.sessions.get(data.sessionId);
this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`);
@@ -1211,6 +1224,7 @@ Object.assign(CodemanApp.prototype, {
['welcomeGeminiBtn', 'gemini'],
['welcomePiBtn', 'pi'],
['welcomeGrokBtn', 'grok'],
['welcomeDeepSeekBtn', 'deepseek'],
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
// without cloudflared can only ever produce "cloudflared not found".
['welcomeTunnelBtn', 'cloudflared'],
+40
View File
@@ -2501,6 +2501,16 @@ body.solo-mode .btn-lifecycle-log {
color: #d4d4d8;
}
/* DeepSeek: the vendor's own brand blue. Deliberately NOT added to the
light-skin `--accent-d` override list above (which rescues gemini/antigravity/
pi/grok, whose pastels wash out on paper backgrounds) — this indigo already
carries enough contrast on the light skins, and overriding it would throw away
the one cue that separates a dsh tab from its neighbours. */
.session-tab .tab-mode.deepseek {
background: rgba(77, 107, 254, 0.18);
color: #7c93ff;
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -4975,6 +4985,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #fafafa;
}
/* DeepSeek mode colors. Same cascade note as pi/grok above: this base-sheet pair
only renders on the `og` skin — the nested `html:not([data-skin="og"])` block
re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so deepseek also
carries a rule inside that block (search `.btn-toolbar.btn-run.mode-deepseek`). */
.btn-toolbar.btn-run.mode-deepseek,
.btn-toolbar.btn-run-gear.mode-deepseek {
background: linear-gradient(135deg, #101a4d 0%, #2740c4 55%, #4d6bfe 100%);
border-color: rgba(124, 147, 255, 0.55);
color: #eef2ff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.btn-toolbar.btn-run.mode-deepseek:hover,
.btn-toolbar.btn-run-gear.mode-deepseek:hover {
background: linear-gradient(135deg, #16225f 0%, #3350e6 55%, #6b83ff 100%);
box-shadow: 0 0 12px rgba(77, 107, 254, 0.35), 0 2px 8px rgba(39, 64, 196, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(150, 170, 255, 0.65);
color: #f8faff;
}
/* Dropdown menu */
.run-mode-menu {
display: none;
@@ -5059,6 +5088,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.run-mode-dot.antigravity { background: #22d3ee; }
.run-mode-dot.pi { background: #f472b6; }
.run-mode-dot.grok { background: #a1a1aa; }
.run-mode-dot.deepseek { background: #4d6bfe; }
.run-mode-dot.shell { background: #94a3b8; }
/* Phone-only Enter button (see index.html). Hidden by default at every width;
@@ -14403,6 +14433,16 @@ html:not([data-skin="og"]) {
color: #fafafa;
}
.btn-toolbar.btn-run.mode-grok:hover { box-shadow: 0 0 14px -2px rgba(161, 161, 170, 0.5); }
/* DeepSeek keeps its indigo on the non-og skins — same specificity trap as pi
and grok above: without this rule the generic `.btn-toolbar.btn-run` in this
nested block wins and deepseek renders as generic claude blue, which is the
one colour it must not be mistaken for. */
.btn-toolbar.btn-run.mode-deepseek {
background: linear-gradient(135deg, #2740c4, #4d6bfe);
border-color: #1b2a8f;
color: #f8faff;
}
.btn-toolbar.btn-run.mode-deepseek:hover { box-shadow: 0 0 14px -2px rgba(77, 107, 254, 0.55); }
.btn-toolbar.btn-run-gear {
background: var(--accent-d);
border-color: var(--accent);
+1 -1
View File
@@ -2214,7 +2214,7 @@ Object.assign(CodemanApp.prototype, {
}
titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir)));
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/grok/shell) + a LIVE pill.
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/grok/deepseek/shell) + a LIVE pill.
const badgeRow = document.createElement('div');
badgeRow.className = 'history-item-badges';
if (s.mode) {
+1 -1
View File
@@ -11,7 +11,7 @@ export interface ResponseViewerTranscriptBlock {
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
// would drag node-pty and the whole session layer into this pure module, so the
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok']);
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek']);
function isPromptLine(line: string): boolean {
return /^\s*›\s*/.test(line);
+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)
// ═══════════════════════════════════════════════════════════════
+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']),
+10
View File
@@ -1436,6 +1436,7 @@ export class WebServer extends EventEmitter {
{ isAntigravityAvailable },
{ isPiAvailable },
{ isGrokAvailable },
{ isDeepSeekRunnable, isDeepSeekAvailable },
{ isCloudflaredAvailable },
{ isGitAvailable },
] = await Promise.all([
@@ -1446,6 +1447,7 @@ export class WebServer extends EventEmitter {
import('../utils/antigravity-cli-resolver.js'),
import('../utils/pi-cli-resolver.js'),
import('../utils/grok-cli-resolver.js'),
import('../utils/deepseek-cli-resolver.js'),
import('../utils/cloudflared-resolver.js'),
import('../git-clone.js'),
]);
@@ -1457,6 +1459,13 @@ export class WebServer extends EventEmitter {
antigravity: isAntigravityAvailable(),
pi: isPiAvailable(),
grok: isGrokAvailable(),
// RUNNABLE, not merely installed: `dsh` is a profile launcher, and a dsh
// with no pane-capable profile would offer a Run button that spawns a
// pane which dies on arrival. The Add-Profile affordance in the run menu
// keys off `deepseekBinary` instead, so a user who has the binary but no
// profile is offered the fix rather than a greyed-out entry.
deepseek: isDeepSeekRunnable(),
deepseekBinary: isDeepSeekAvailable(),
cloudflared: isCloudflaredAvailable(),
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
// keep without git (issue #236), same reasoning as cloudflared above.
@@ -2738,6 +2747,7 @@ export class WebServer extends EventEmitter {
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
+7 -1
View File
@@ -182,7 +182,13 @@ const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
* infinite-wait-dressed-as-a-timeout this guard exists to prevent.
*/
export function hooksAvailableForMode(mode: SessionMode): boolean {
return mode === 'claude';
// `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE
// signals rather than having them inferred. The DeepSeek Harness terminal
// front door reports idle/working/blocked to its supervisor, and Codeman is
// that supervisor (see deepseek-status-shim.ts), so a dsh session really can
// deliver `stop` and `blocked`. Every other mode is output-stabilization
// guesswork and must keep failing the ask.
return mode === 'claude' || mode === 'deepseek';
}
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
+11 -2
View File
@@ -5,7 +5,7 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 156 event constants organized by category:
* 157 event constants organized by category:
* - **Core** (1): init
* - **Transport** (1): sse:heartbeat
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
@@ -25,7 +25,8 @@
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image / attachments** (2): image:detected, attachment:detected
* - **Hooks** (8): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed
* - **Hooks** (9): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook)
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
* - **Clipboard** (1): write
@@ -360,6 +361,13 @@ export const HookElicitationComplete = 'hook:elicitation_complete' as const;
export const HookElicitationResponse = 'hook:elicitation_response' as const;
/** Claude Code hook: response complete. */
export const HookStop = 'hook:stop' as const;
/**
* Agent started a turn. NOT a Claude Code hook: this one is reported by the
* DeepSeek Harness status bridge, which is why the name is agent-generic. It
* exists so a dialog answered in the terminal clears its alert immediately
* instead of waiting for the turn to end.
*/
export const HookAgentWorking = 'hook:agent_working' as const;
/** Claude Code hook: teammate went idle. */
export const HookTeammateIdle = 'hook:teammate_idle' as const;
/** Claude Code hook: teammate task completed. */
@@ -619,6 +627,7 @@ export const SseEvent = {
HookElicitationComplete,
HookElicitationResponse,
HookStop,
HookAgentWorking,
HookTeammateIdle,
HookTaskCompleted,