mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 14:09: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:
@@ -9,6 +9,7 @@
|
||||
|
||||
import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js';
|
||||
import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js';
|
||||
import { DEEPSEEK_VERSION_REGEX } from '../utils/deepseek-cli-resolver.js';
|
||||
|
||||
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
|
||||
|
||||
@@ -163,6 +164,32 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dsh',
|
||||
label: 'DeepSeek Harness CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['DeepSeek sessions'],
|
||||
// Version match required, and for a sharper reason than pi or grok: `dsh` is
|
||||
// not merely a squattable npm name, it is an existing Debian program
|
||||
// (dancer's shell, `apt install dsh`). The run mode's resolver additionally
|
||||
// demands the harness's own help banner before it will point a spawn line at
|
||||
// a candidate; the doctor is advisory and settles for the shared
|
||||
// DEEPSEEK_VERSION_REGEX, so the two cannot disagree about the VERSION even
|
||||
// though the resolver is the stricter of the pair about IDENTITY.
|
||||
resolvers: [
|
||||
{
|
||||
match: ALL,
|
||||
resolver: {
|
||||
kind: 'path',
|
||||
bins: ['dsh'],
|
||||
versionArg: '--version',
|
||||
versionRegex: DEEPSEEK_VERSION_REGEX,
|
||||
requireVersionMatch: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'libreoffice',
|
||||
label: 'LibreOffice',
|
||||
|
||||
@@ -48,8 +48,9 @@ const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
|
||||
* answer "yes" to, which then loads and EXECUTES repo-local `.pi/extensions` TypeScript,
|
||||
* so `approveProjectTrust: false` (`--no-approve`) is materialized. Omitting `--approve`
|
||||
* is NOT a clamp.
|
||||
* Codex, antigravity and grok need nothing here: their absent config already spawns safe
|
||||
* (grok's bare spawn is its own ask-mode default; --always-approve is only ever sent).
|
||||
* Codex, antigravity, grok and deepseek need nothing here: their absent config already spawns safe
|
||||
* (grok's bare spawn is its own ask-mode default and deepseek's omits DSH_PERMISSION_MODE
|
||||
* entirely, leaving the harness on workspace-write, which asks; both switches are only ever sent).
|
||||
* Granted/admin/single-user get undefined for both, i.e. upstream defaults untouched.
|
||||
*/
|
||||
export function clampCronExternalCliConfigs(
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* @fileoverview The DeepSeek Harness -> Codeman status bridge.
|
||||
*
|
||||
* ## Why this exists
|
||||
*
|
||||
* Every external CLI mode before this one (opencode, codex, gemini, antigravity,
|
||||
* pi, grok) is READINESS-GUESSED: Codeman watches the PTY go quiet and infers a
|
||||
* turn ended. Claude is the exception, because Claude Code fires real hooks. The
|
||||
* DeepSeek Harness TUI gives us a third option, and a much better one than
|
||||
* guessing: the community terminal front door already reports its own lifecycle
|
||||
* to an owning supervisor, and it does so through a fully GENERIC, env-var-gated
|
||||
* contract it inherited from Herdr (herdr.dev).
|
||||
*
|
||||
* When all three of `HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set,
|
||||
* the TUI shells out on every state change:
|
||||
*
|
||||
* "$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
|
||||
* --source custom:dsh-tui --agent dsh-tui \
|
||||
* --state idle|working|blocked [--message <text>] --seq <n>
|
||||
*
|
||||
* and treats exit code 0 as "delivered" (retrying with backoff otherwise). So
|
||||
* Codeman points `HERDR_BIN_PATH` at the script below and gets DEFINITIVE
|
||||
* idle/working/blocked signals for dsh sessions: real respawn triggers, real
|
||||
* `wait`/`wait-output` stop+blocked signals, and real Approvals Inbox items,
|
||||
* on par with Claude's hooks rather than with output stabilization.
|
||||
*
|
||||
* This is an interface implementation, not an impersonation: we implement the
|
||||
* one verb (`pane report-agent`) that the contract defines, and nothing on the
|
||||
* machine ever executes a real `herdr` binary — `HERDR_BIN_PATH` is our own
|
||||
* script, in our own data dir. `HERDR_ENV=1` is the flag the TUI checks to know
|
||||
* a supervisor is present; a supervisor IS present, it is Codeman.
|
||||
*
|
||||
* ## Why it is generated rather than committed
|
||||
*
|
||||
* The shim must be an executable file at a stable absolute path in every
|
||||
* install shape: a git clone (where `scripts/` exists), an `npm i -g aicodeman`
|
||||
* (where `files` ships only `dist` plus two named scripts), and any
|
||||
* `CODEMAN_INSTANCE`. Writing it into the data dir at session-create time makes
|
||||
* one code path cover all of them, single-sources the content here in TS, and
|
||||
* follows the precedent of `self-update-runner.sh`. It is rewritten whenever the
|
||||
* embedded version marker changes, so an upgraded Codeman refreshes a stale shim
|
||||
* without the user knowing it exists.
|
||||
*
|
||||
* @module deepseek-status-shim
|
||||
*/
|
||||
|
||||
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from './config/instance.js';
|
||||
|
||||
/**
|
||||
* Bumped whenever SHIM_SOURCE changes. The marker is embedded in the generated
|
||||
* file, so `ensureDeepSeekStatusShim()` can tell a current shim from one written
|
||||
* by an older Codeman and rewrite only when needed (rather than rewriting on
|
||||
* every session create, or — worse — leaving a stale one in place forever).
|
||||
*/
|
||||
const SHIM_VERSION = 1;
|
||||
const SHIM_MARKER = `codeman-dsh-status-shim v${SHIM_VERSION}`;
|
||||
|
||||
/**
|
||||
* Mapping from the harness's three lifecycle states to Codeman hook events.
|
||||
*
|
||||
* - `blocked` -> `permission_prompt`: the TUI reports blocked when a tool
|
||||
* approval or an `ask_user_question` questionnaire is on screen, which is
|
||||
* exactly the red "needs you" alert and an answerable Approvals Inbox item.
|
||||
* - `idle` -> `stop`: the definitive end-of-turn signal, the one respawn and the
|
||||
* wait endpoints care about.
|
||||
* - `working` -> `agent_working`: a turn STARTED. Codeman infers "working" from
|
||||
* PTY output well enough on its own, but the event is what RESOLVES a pending
|
||||
* approval when the user answers a dialog in the terminal instead of in the
|
||||
* inbox. Without it a dsh session's red alert would survive until the next
|
||||
* `stop`, which is the exact stuck-alert bug the claude path already had to
|
||||
* fix once (and the pane-capture staleness sweep that fixed it there is
|
||||
* Claude-dialog-shaped, so it cannot help here).
|
||||
*/
|
||||
export const DEEPSEEK_STATE_TO_HOOK_EVENT: Readonly<Record<string, string>> = Object.freeze({
|
||||
idle: 'stop',
|
||||
blocked: 'permission_prompt',
|
||||
working: 'agent_working',
|
||||
});
|
||||
|
||||
/**
|
||||
* The generated script.
|
||||
*
|
||||
* Constraints it must satisfy, each learned from an existing Codeman hook bug:
|
||||
* - **TLS**: `CODEMAN_API_URL` is loopback HTTPS with a self-signed cert on
|
||||
* `--https`/tailscale installs, so certificate verification is disabled for
|
||||
* the request. Without this the whole bridge dies silently, exactly as the
|
||||
* claude hook curls did before they grew `-k`.
|
||||
* - **Secret**: the hook-secret file is read AT EXECUTION TIME, never baked in,
|
||||
* so rotation needs no respawn and the value never lands on a command line.
|
||||
* - **Exit codes**: 0 means delivered. Anything else makes the TUI retry with
|
||||
* backoff, so transport failures self-heal, but an unknown verb or an
|
||||
* unmapped state exits 0 to avoid a pointless retry storm over something that
|
||||
* will never succeed.
|
||||
* - **Timeout**: bounded below the caller's own 2s budget, so we lose the race
|
||||
* deliberately rather than being killed mid-flight.
|
||||
*/
|
||||
const SHIM_SOURCE = `#!/usr/bin/env node
|
||||
// ${SHIM_MARKER}
|
||||
// GENERATED BY CODEMAN — do not edit. Rewritten from src/deepseek-status-shim.ts
|
||||
// whenever its version marker changes.
|
||||
//
|
||||
// Implements the one verb the DeepSeek Harness TUI's supervisor contract uses:
|
||||
// pane report-agent <paneId> --state <idle|working|blocked> [--message <t>] ...
|
||||
// and forwards it to this Codeman instance as a hook event.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
|
||||
const STATE_TO_EVENT = ${JSON.stringify(DEEPSEEK_STATE_TO_HOOK_EVENT)}
|
||||
const TIMEOUT_MS = 1500
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const flag = (name) => {
|
||||
const i = argv.indexOf(name)
|
||||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined
|
||||
}
|
||||
|
||||
// Unknown verb: succeed silently. Retrying could never make it succeed, and a
|
||||
// non-zero exit here would make the caller retry four times per state change.
|
||||
if (argv[0] !== 'pane' || argv[1] !== 'report-agent') process.exit(0)
|
||||
|
||||
const event = STATE_TO_EVENT[String(flag('--state') ?? '')]
|
||||
if (!event) process.exit(0)
|
||||
|
||||
// The pane id we hand the TUI IS the Codeman session id, but prefer the ambient
|
||||
// env: it is set by the same code that set HERDR_PANE_ID and cannot be spoofed
|
||||
// by an argument the agent itself could influence.
|
||||
const sessionId = process.env.CODEMAN_SESSION_ID || argv[2]
|
||||
const apiUrl = process.env.CODEMAN_API_URL
|
||||
if (!sessionId || !apiUrl) process.exit(1)
|
||||
|
||||
let secret = ''
|
||||
try {
|
||||
secret = readFileSync(process.env.CODEMAN_HOOK_SECRET_FILE || '', 'utf-8').trim()
|
||||
} catch {
|
||||
// Missing file: the loopback bypass still applies when no tunnel is running.
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
event,
|
||||
sessionId,
|
||||
data: {
|
||||
source: 'dsh-status-shim',
|
||||
agent: flag('--agent') || 'dsh',
|
||||
...(flag('--message') ? { message: flag('--message') } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL('/api/hook-event', apiUrl)
|
||||
} catch {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const transport = url.protocol === 'https:' ? https : http
|
||||
const req = transport.request(
|
||||
{
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'X-Codeman-Hook-Secret': secret,
|
||||
},
|
||||
// Loopback HTTPS with a self-signed cert (--https / tailscale installs).
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
(res) => {
|
||||
res.resume()
|
||||
process.exit(res.statusCode && res.statusCode >= 200 && res.statusCode < 300 ? 0 : 1)
|
||||
}
|
||||
)
|
||||
req.on('timeout', () => {
|
||||
req.destroy()
|
||||
process.exit(1)
|
||||
})
|
||||
req.on('error', () => process.exit(1))
|
||||
req.end(body)
|
||||
`;
|
||||
|
||||
/** Absolute path of the generated shim for this instance. */
|
||||
export function deepSeekStatusShimPath(): string {
|
||||
return dataPath('dsh-status-shim.mjs');
|
||||
}
|
||||
|
||||
let ensuredThisProcess = false;
|
||||
|
||||
/**
|
||||
* Write the shim if it is missing or stale, and return its path.
|
||||
*
|
||||
* Idempotent and cheap: after the first call in a process it does nothing, and
|
||||
* even the first call only rewrites when the on-disk marker differs. Never
|
||||
* throws — a data dir that cannot be written is a degraded status bridge, not a
|
||||
* failed session start, so callers fall back to output-stabilization readiness
|
||||
* by receiving null.
|
||||
*/
|
||||
export function ensureDeepSeekStatusShim(): string | null {
|
||||
const path = deepSeekStatusShimPath();
|
||||
if (ensuredThisProcess) return path;
|
||||
try {
|
||||
let current = '';
|
||||
try {
|
||||
current = readFileSync(path, 'utf-8');
|
||||
} catch {
|
||||
// Missing — fall through to the write.
|
||||
}
|
||||
if (!current.includes(SHIM_MARKER)) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, SHIM_SOURCE, { mode: 0o700 });
|
||||
}
|
||||
// Re-assert the mode even when the content matched: a shim that lost its
|
||||
// executable bit (a restored backup, a copied data dir) would make every
|
||||
// report fail, and the TUI would retry four times per state change forever.
|
||||
chmodSync(path, 0o700);
|
||||
ensuredThisProcess = true;
|
||||
return path;
|
||||
} catch (err) {
|
||||
console.warn(`[DeepSeek] Could not install the status shim at ${path}: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: forget the per-process memo so a fresh temp HOME is re-provisioned. */
|
||||
export function resetDeepSeekStatusShimForTest(): void {
|
||||
ensuredThisProcess = false;
|
||||
}
|
||||
@@ -146,6 +146,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
|
||||
antigravity: 'exec agy',
|
||||
pi: 'exec pi',
|
||||
grok: 'exec grok',
|
||||
deepseek: 'exec dsh',
|
||||
};
|
||||
return commands[mode as DockerCommandMode] || commands.shell;
|
||||
}
|
||||
@@ -625,6 +626,17 @@ const CRED_STORES: CredStorePolicy[] = [
|
||||
rel: '.grok',
|
||||
seedFiles: ['auth.json', 'config.toml', 'pager.toml'],
|
||||
},
|
||||
// DeepSeek Harness keeps credentials in `~/.dsh/.env` (0600) and composition in
|
||||
// `settings.yaml` / `cordis.patch.yml`. `profiles/` is deliberately NOT seeded:
|
||||
// it is a pnpm workspace holding a full node_modules tree per profile, which is
|
||||
// both enormous and host-arch-specific. An in-container dsh therefore needs its
|
||||
// profile installed IN the image (see docker/agent.Dockerfile), and the seeded
|
||||
// files only supply auth and model composition. Same host-invisibility trade-off
|
||||
// as pi and grok: `~/.dsh/sessions` inside a container is that container's own.
|
||||
{
|
||||
rel: '.dsh',
|
||||
seedFiles: ['.env', 'settings.yaml', 'cordis.patch.yml'],
|
||||
},
|
||||
{ rel: '.config/gcloud', seedWhole: true },
|
||||
{ rel: '.config/opencode', seedWhole: true },
|
||||
];
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
AntigravityConfig,
|
||||
PiConfig,
|
||||
GrokConfig,
|
||||
DeepSeekConfig,
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
} from './types.js';
|
||||
@@ -80,6 +81,7 @@ export interface CreateSessionOptions {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
||||
@@ -113,6 +115,7 @@ export interface RespawnPaneOptions {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||
|
||||
@@ -115,6 +115,10 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
||||
antigravity: remoteLoginShellCommand('agy'),
|
||||
pi: remoteLoginShellCommand('pi'),
|
||||
grok: remoteLoginShellCommand('grok'),
|
||||
// `dsh` alone boots nothing: the launcher needs a profile, and the remote box's
|
||||
// profile inventory is unknown here. The per-host `commands.deepseek` override
|
||||
// is the escape hatch for naming one.
|
||||
deepseek: remoteLoginShellCommand('dsh'),
|
||||
};
|
||||
return commands[mode as RemoteCommandMode] || commands.shell;
|
||||
}
|
||||
|
||||
+26
-2
@@ -52,6 +52,7 @@ import {
|
||||
type AntigravityConfig,
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type DeepSeekConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
} from './types.js';
|
||||
@@ -178,7 +179,8 @@ export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
mode === 'gemini' ||
|
||||
mode === 'antigravity' ||
|
||||
mode === 'pi' ||
|
||||
mode === 'grok'
|
||||
mode === 'grok' ||
|
||||
mode === 'deepseek'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,6 +198,8 @@ function getModeLabel(mode: SessionMode): string {
|
||||
return 'Pi';
|
||||
case 'grok':
|
||||
return 'Grok';
|
||||
case 'deepseek':
|
||||
return 'DeepSeek';
|
||||
case 'shell':
|
||||
return 'Shell';
|
||||
case 'claude':
|
||||
@@ -521,6 +525,9 @@ export class Session extends EventEmitter {
|
||||
private _piConfig: PiConfig | undefined;
|
||||
// Grok configuration (only for mode === 'grok')
|
||||
private _grokConfig: GrokConfig | undefined;
|
||||
|
||||
// DeepSeek Harness configuration (only for mode === 'deepseek')
|
||||
private _deepSeekConfig: DeepSeekConfig | undefined;
|
||||
private _resumeSessionId: string | undefined;
|
||||
|
||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||
@@ -618,6 +625,8 @@ export class Session extends EventEmitter {
|
||||
piConfig?: PiConfig;
|
||||
/** Grok configuration (only for mode === 'grok') */
|
||||
grokConfig?: GrokConfig;
|
||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** Resume a previous Claude conversation (used after server reboot) */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
||||
@@ -727,6 +736,11 @@ export class Session extends EventEmitter {
|
||||
this._piConfig = config.piConfig;
|
||||
}
|
||||
|
||||
// Apply DeepSeek Harness configuration
|
||||
if (config.deepSeekConfig) {
|
||||
this._deepSeekConfig = config.deepSeekConfig;
|
||||
}
|
||||
|
||||
// Apply Grok configuration
|
||||
if (config.grokConfig) {
|
||||
this._grokConfig = config.grokConfig;
|
||||
@@ -1325,6 +1339,7 @@ export class Session extends EventEmitter {
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
@@ -1501,7 +1516,8 @@ export class Session extends EventEmitter {
|
||||
this.mode === 'gemini' ||
|
||||
this.mode === 'antigravity' ||
|
||||
this.mode === 'pi' ||
|
||||
this.mode === 'grok'
|
||||
this.mode === 'grok' ||
|
||||
this.mode === 'deepseek'
|
||||
),
|
||||
})
|
||||
);
|
||||
@@ -1572,6 +1588,7 @@ export class Session extends EventEmitter {
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
@@ -1831,6 +1848,7 @@ export class Session extends EventEmitter {
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
@@ -1924,6 +1942,12 @@ export class Session extends EventEmitter {
|
||||
if (this.mode === 'grok') {
|
||||
throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
// DeepSeek sessions require tmux for DEEPSEEK_API_KEY / DSH_PERMISSION_MODE
|
||||
// injection via setenv — and for the HERDR_* status-bridge triple, without
|
||||
// which the mode silently loses its definitive idle/blocked signals.
|
||||
if (this.mode === 'deepseek') {
|
||||
throw new Error('DeepSeek Harness sessions require tmux. Direct PTY fallback is not supported.');
|
||||
}
|
||||
try {
|
||||
// Pass --session-id to use the SAME ID as the Codeman session
|
||||
// This ensures subagents can be directly matched to the correct tab
|
||||
|
||||
+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);
|
||||
|
||||
@@ -1014,6 +1014,7 @@ const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string
|
||||
{ id: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' },
|
||||
{ id: 'pi', label: 'pi', detail: 'pi.dev' },
|
||||
{ id: 'grok', label: 'grok', detail: 'xAI Grok Build' },
|
||||
{ id: 'deepseek', label: 'deepseek', detail: 'DeepSeek Harness (dsh)' },
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -149,7 +149,7 @@ export type TuiAnswerResult =
|
||||
|
||||
export interface TuiQuickStartOptions {
|
||||
caseName: string;
|
||||
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok';
|
||||
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek';
|
||||
sessionName?: string;
|
||||
/** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */
|
||||
parentSessionId?: string;
|
||||
|
||||
+71
-4
@@ -8,7 +8,7 @@
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' (which CLI backend)
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
@@ -17,6 +17,7 @@
|
||||
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
||||
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
|
||||
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
|
||||
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
|
||||
@@ -45,11 +46,20 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
|
||||
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
|
||||
|
||||
/** Session mode: which CLI backend a session runs */
|
||||
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok';
|
||||
export type SessionMode =
|
||||
| 'claude'
|
||||
| 'shell'
|
||||
| 'opencode'
|
||||
| 'codex'
|
||||
| 'gemini'
|
||||
| 'antigravity'
|
||||
| 'pi'
|
||||
| 'grok'
|
||||
| 'deepseek';
|
||||
|
||||
export type RemoteCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -158,7 +168,7 @@ export interface RemoteSessionInfo {
|
||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||
export type DockerCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'
|
||||
>;
|
||||
|
||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||
@@ -388,6 +398,61 @@ export interface GrokConfig {
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek Harness (`dsh`) session configuration.
|
||||
*
|
||||
* Two things make this config shaped unlike every sibling above it.
|
||||
*
|
||||
* **1. The agent is a PROFILE, not the binary.** `dsh` is a launcher: it boots
|
||||
* `$DSH_HOME/profiles/<name>`, an ordered stack of plugin-bundle patch layers.
|
||||
* DeepSeek ships only `web`, `headless` and `base`, so the interactive terminal
|
||||
* agent is always a third-party profile the user installed. `profile` is
|
||||
* therefore the primary knob, and an absent one resolves to the first
|
||||
* pane-capable profile found (see resolveDefaultDeepSeekProfile).
|
||||
*
|
||||
* **2. Permissions are an ENV VAR, not a flag.** The harness has no
|
||||
* `--dangerously-skip-permissions` equivalent; its sandbox and approval rows are
|
||||
* config, driven by one documented input, `DSH_PERMISSION_MODE`, with three
|
||||
* presets (measured from `dsh --dump-default-config`):
|
||||
*
|
||||
* read-only sandbox read-only, approval ask
|
||||
* workspace-write sandbox workspace-write, approval ask <- default
|
||||
* danger-full-access sandbox danger-full-access, approval never
|
||||
*
|
||||
* This is the one place a Codeman env export is the RIGHT mechanism rather than
|
||||
* the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks
|
||||
* in-session `/effort`), `DSH_PERMISSION_MODE` is read with `??` as a boot-time
|
||||
* DEFAULT, so it stays a soft default the user can still change in-session. It
|
||||
* is exported via `tmux setenv`, never on the spawn command line.
|
||||
*/
|
||||
export interface DeepSeekConfig {
|
||||
/**
|
||||
* Profile under `$DSH_HOME/profiles` to boot (`dsh --profile <name>`). Absent
|
||||
* = the first pane-capable profile installed. A `web`/`headless` profile is
|
||||
* refused at spawn time: neither can drive an interactive pane.
|
||||
*/
|
||||
profile?: string;
|
||||
/**
|
||||
* Sandbox + approval preset, exported as `DSH_PERMISSION_MODE`. Absent = the
|
||||
* harness's own `workspace-write` default, which still ASKS — which is why the
|
||||
* multi-user clamp only needs the only-if-sent branch here, like
|
||||
* codex/antigravity/grok rather than pi.
|
||||
*/
|
||||
permissionMode?: 'read-only' | 'workspace-write' | 'danger-full-access';
|
||||
/** Resume the most recent session for this workspace (`--resume`). */
|
||||
resumeSession?: boolean;
|
||||
/** Resume a specific session by ID (`--resume <id>`). Wins over resumeSession. */
|
||||
resumeSessionId?: string;
|
||||
/**
|
||||
* Report idle/working/blocked back to Codeman through the Herdr-compatible
|
||||
* status shim (see `deepseek-status-shim.ts`). Default ON: it upgrades this
|
||||
* mode from output-stabilization guessing to definitive hook events. Only
|
||||
* TUIs that implement the contract report; for one that does not, this is
|
||||
* inert rather than harmful.
|
||||
*/
|
||||
statusReporting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating a new session
|
||||
*/
|
||||
@@ -553,6 +618,8 @@ export interface SessionState {
|
||||
piConfig?: PiConfig;
|
||||
/** Grok-specific configuration (only for mode === 'grok') */
|
||||
grokConfig?: GrokConfig;
|
||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* @fileoverview Resolve the DeepSeek Harness CLI (`dsh`) binary and its bootable profiles.
|
||||
*
|
||||
* Mirrors pi-cli-resolver.ts / grok-cli-resolver.ts, but the identity probe here
|
||||
* is STRICTER than either, and deliberately so: `dsh` is not merely a short name
|
||||
* with npm squatters, it is an EXISTING, widely packaged Unix program. Debian and
|
||||
* Ubuntu ship `dsh` = "dancer's shell" / distributed shell (`apt install dsh`),
|
||||
* which like nearly every Unix tool prints a version-shaped string of its own.
|
||||
* A version-token probe alone (which is all pi and grok need) would
|
||||
* therefore ACCEPT dancer's shell as the DeepSeek Harness and hand it to a spawn
|
||||
* line, so every candidate must additionally prove its identity by printing the
|
||||
* harness's own help banner.
|
||||
*
|
||||
* Two probes per candidate, both bounded and both cached behind the shared
|
||||
* resolver's positive/negative caching:
|
||||
* 1. `dsh --help` must match DEEPSEEK_IDENTITY_REGEX (`DeepSeek Harness`)
|
||||
* 2. `dsh --version` must yield a version token (real output: `0.1.1-rc.2`)
|
||||
* Order matters: identity is checked FIRST, so a foreign `dsh` is rejected on the
|
||||
* cheaper, more discriminating signal and never contributes a version number.
|
||||
*
|
||||
* `dsh` is a profile LAUNCHER, not an agent: `dsh --profile <name>` boots an
|
||||
* ordered stack of plugin-bundle patch layers, and DeepSeek ships only `web`
|
||||
* (browser UI), `headless` (one-shot) and `base` (no app). The interactive
|
||||
* terminal agent Codeman actually drives is a THIRD-PARTY profile the user
|
||||
* installs. That is why this module resolves two independent things — a binary
|
||||
* AND a profile inventory — and why "available" for the deepseek run mode means
|
||||
* both (`isDeepSeekRunnable`, and `resolveDeepSeekLaunchError` in session-routes.ts
|
||||
* for the actionable per-half message).
|
||||
*
|
||||
* @module utils/deepseek-cli-resolver
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import {
|
||||
createCliExecutableResolver,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from './cli-executable-resolver.js';
|
||||
|
||||
/**
|
||||
* Common directories where the `dsh` binary may be installed.
|
||||
*
|
||||
* `dsh` is an npm package (`@deepseek-ai/dsh`), so unlike grok there is no
|
||||
* vendor-owned install dir to lead with: the global npm bin is wherever the
|
||||
* user's prefix points. `~/.local/bin` heads the list because it is the default
|
||||
* for a prefix-relocated npm (and is where this box's install landed).
|
||||
*/
|
||||
const DEEPSEEK_SEARCH_DIRS = [
|
||||
join(homedir(), '.local', 'bin'),
|
||||
'/usr/local/bin',
|
||||
join(homedir(), '.npm-global', 'bin'),
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/**
|
||||
* A real `dsh --version` prints a bare `0.1.1-rc.2` (measured, 0.1.1-rc.2), so
|
||||
* the prerelease suffix is part of the token — truncating it to `0.1.1` would
|
||||
* misreport a release-candidate as a release in `codeman doctor`.
|
||||
*
|
||||
* Exported and SHARED with the `dsh` entry in `config/dependency-registry.ts`,
|
||||
* so the doctor and the run mode cannot disagree about what counts as an
|
||||
* installed dsh (the same single-source rule as PI_VERSION_REGEX /
|
||||
* GROK_VERSION_REGEX). Shape is dictated by the doctor's `extractVersion()`
|
||||
* (first capture group, whole-output scan): hence a capturing group and a
|
||||
* leading boundary instead of `^`. No `g` flag, so there is no shared
|
||||
* `lastIndex` to reset.
|
||||
*/
|
||||
export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)/;
|
||||
|
||||
/**
|
||||
* The identity marker that separates DeepSeek's `dsh` from Debian's dancer's
|
||||
* shell. The real launcher's `--help` banner reads:
|
||||
*
|
||||
* dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle …
|
||||
*
|
||||
* Matched case-insensitively against the help output. This is the check that
|
||||
* makes the resolver safe to point a spawn line at; see the module header.
|
||||
*/
|
||||
export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i;
|
||||
|
||||
const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh';
|
||||
|
||||
/** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */
|
||||
export function resolveDshHome(): string {
|
||||
const fromEnv = process.env.DSH_HOME?.trim();
|
||||
return fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh');
|
||||
}
|
||||
|
||||
/**
|
||||
* What a profile is FOR, inferred from the bundles it composes.
|
||||
*
|
||||
* `interactive` is the only kind a tmux pane can drive: `web` serves a browser
|
||||
* UI and would occupy the pane with a logging server, `headless` answers one
|
||||
* task and exits (which reads as an instantly-dead pane). `unknown` is treated
|
||||
* as interactive-capable on purpose — the whole point of the harness is that
|
||||
* anyone can publish an app bundle, so an unrecognized third-party profile must
|
||||
* not be hidden from the picker just because this list has not heard of it.
|
||||
*/
|
||||
export type DeepSeekProfileKind = 'interactive' | 'web' | 'headless' | 'unknown';
|
||||
|
||||
export interface DeepSeekProfile {
|
||||
/** Directory name under `$DSH_HOME/profiles`, i.e. the `--profile` argument. */
|
||||
name: string;
|
||||
/** Bundle package names composed by the profile, in order. */
|
||||
bundles: string[];
|
||||
kind: DeepSeekProfileKind;
|
||||
}
|
||||
|
||||
/** Bundles that positively identify a non-interactive profile. */
|
||||
const WEB_BUNDLE_PATTERN = /dsh-web-app|dsh-web-frontend/i;
|
||||
const HEADLESS_BUNDLE_PATTERN = /dsh-headless/i;
|
||||
/**
|
||||
* Bundles that positively identify a terminal app. Intentionally a loose
|
||||
* community-wide pattern rather than one blessed package: the terminal front
|
||||
* door is third-party by construction (DeepSeek ships none), and a dozen
|
||||
* scoped `dsh-tui` packages from a dozen different authors compete. Anything
|
||||
* matching is a TUI; anything unmatched is `unknown`, which still counts as
|
||||
* launchable.
|
||||
*/
|
||||
const TUI_BUNDLE_PATTERN = /dsh-tui|dsh-terminal-app|tui/i;
|
||||
|
||||
/** Profile directory names that are not profiles. */
|
||||
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
|
||||
|
||||
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
|
||||
const haystack = [name, ...bundles].join(' ');
|
||||
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
|
||||
// web profile as far as a tmux pane is concerned, because the web app owns the
|
||||
// process and blocks.
|
||||
if (WEB_BUNDLE_PATTERN.test(haystack)) return 'web';
|
||||
if (HEADLESS_BUNDLE_PATTERN.test(haystack)) return 'headless';
|
||||
if (TUI_BUNDLE_PATTERN.test(haystack)) return 'interactive';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single profile directory's `package.json` and return its bundle list.
|
||||
* Returns null for anything that is not a readable dsh profile, so a stray
|
||||
* directory under `profiles/` cannot break the inventory.
|
||||
*/
|
||||
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
|
||||
try {
|
||||
const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
|
||||
const rawBundles = parsed?.dsh?.profile?.bundles;
|
||||
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
|
||||
return { name, bundles, kind: classifyProfile(name, bundles) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory the profiles installed under `$DSH_HOME/profiles`.
|
||||
*
|
||||
* Never throws: a missing DSH_HOME (dsh installed but never run) is an empty
|
||||
* list, which the callers render as "no profile yet" rather than an error.
|
||||
* Deliberately un-cached — a user can create a profile at any moment (including
|
||||
* through Codeman's own bootstrap), and the directory scan is cheap next to the
|
||||
* two process spawns the binary probe already costs.
|
||||
*/
|
||||
export function listDeepSeekProfiles(): DeepSeekProfile[] {
|
||||
const profilesDir = join(resolveDshHome(), 'profiles');
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(profilesDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.'))
|
||||
.map((e) => e.name);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.map((name) => readProfile(profilesDir, name))
|
||||
.filter((p): p is DeepSeekProfile => p !== null)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* The profile a session should boot when the user picked none.
|
||||
*
|
||||
* Prefers a positively-identified terminal profile, then an unrecognized one
|
||||
* (third-party by construction — see TUI_BUNDLE_PATTERN), and refuses to fall
|
||||
* back to `web`/`headless`, which cannot drive a pane. Returns null when nothing
|
||||
* launchable is installed, which is what makes the mode report unavailable
|
||||
* instead of spawning a pane that dies on arrival.
|
||||
*/
|
||||
export function resolveDefaultDeepSeekProfile(profiles: DeepSeekProfile[] = listDeepSeekProfiles()): string | null {
|
||||
return (
|
||||
profiles.find((p) => p.kind === 'interactive')?.name ?? profiles.find((p) => p.kind === 'unknown')?.name ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** True when the profile can occupy a tmux pane as an interactive agent. */
|
||||
export function isLaunchableProfile(profile: DeepSeekProfile): boolean {
|
||||
return profile.kind === 'interactive' || profile.kind === 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the two-stage identity+version probe on a candidate path.
|
||||
*
|
||||
* Returns the version token only when the binary proves it is the DeepSeek
|
||||
* Harness launcher. Returns null for anything else: a missing binary, a
|
||||
* non-zero exit, a hang (timeout), a help banner without the harness marker
|
||||
* (this is the dancer's-shell rejection), or output with no version-shaped
|
||||
* token.
|
||||
*
|
||||
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
||||
* whether the dev box happens to have dsh installed — and since `dsh` names a
|
||||
* real Debian program, this probe would EXECUTE whatever binary of that name the
|
||||
* machine carries. The shared resolver host is already inert under vitest, so
|
||||
* this gate is defense in depth for any opted-in host that still carries the
|
||||
* default probe; tests drive resolution via `createDeepSeekResolverForTest`,
|
||||
* whose injected probe bypasses it. Pinned by test/deepseek-cli-resolver.test.ts.
|
||||
*/
|
||||
function probeDeepSeekVersion(binPath: string): string | null {
|
||||
if (process.env.VITEST) return null;
|
||||
const run = (args: string[]): string | null => {
|
||||
try {
|
||||
return execFileSync(binPath, args, {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// A stuck or hostile `dsh` that ignores SIGTERM would survive the timeout
|
||||
// and block the server (execFileSync keeps waiting after the signal).
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[DeepSeekResolver] Ignoring ${binPath}: "dsh ${args.join(' ')}" failed (${(err as Error).message})`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Identity first — the discriminating signal, and the one that keeps Debian's
|
||||
// dancer's shell out of a spawn line.
|
||||
const help = run(['--help']);
|
||||
if (help === null) return null;
|
||||
if (!DEEPSEEK_IDENTITY_REGEX.test(help)) {
|
||||
console.warn(
|
||||
`[DeepSeekResolver] Ignoring ${binPath}: "dsh --help" is not the DeepSeek Harness launcher ` +
|
||||
`(printed ${JSON.stringify(help.slice(0, 80))}). A different program named "dsh" (e.g. Debian's ` +
|
||||
`dancer's shell) is earlier on PATH.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const out = run(['--version']);
|
||||
if (out === null) return null;
|
||||
const candidate = DEEPSEEK_VERSION_REGEX.exec(out)?.[1];
|
||||
if (candidate) return candidate;
|
||||
console.warn(`[DeepSeekResolver] Ignoring ${binPath}: "dsh --version" printed ${JSON.stringify(out.slice(0, 80))}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
type DeepSeekVersionProbe = (binPath: string) => string | null;
|
||||
|
||||
function createDeepSeekResolver(
|
||||
host?: CliResolverHost,
|
||||
versionProbe: DeepSeekVersionProbe = probeDeepSeekVersion,
|
||||
now?: () => number
|
||||
) {
|
||||
return createCliExecutableResolver<string>(
|
||||
{
|
||||
binary: 'dsh',
|
||||
searchDirs: DEEPSEEK_SEARCH_DIRS,
|
||||
validateCandidate: (binPath) => {
|
||||
const version = versionProbe(binPath);
|
||||
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||
},
|
||||
now,
|
||||
},
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated DeepSeek wrapper around an injected host, version probe
|
||||
* and clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe,
|
||||
* which is exactly what the hermeticity test exercises.
|
||||
*/
|
||||
export function createDeepSeekResolverForTest(
|
||||
host: CliResolverHost,
|
||||
versionProbe?: DeepSeekVersionProbe,
|
||||
now?: () => number
|
||||
) {
|
||||
return createDeepSeekResolver(host, versionProbe ?? probeDeepSeekVersion, now);
|
||||
}
|
||||
|
||||
const deepSeekResolver = createDeepSeekResolver();
|
||||
|
||||
/**
|
||||
* Finds the directory containing a verified `dsh` binary.
|
||||
* Checks the server PATH first, then the common install locations. Every
|
||||
* candidate must pass the identity+version probe before it is accepted.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveDeepSeekDir(): string | null {
|
||||
return deepSeekResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the `dsh` BINARY is installed. Note this is deliberately weaker than
|
||||
* what the run mode needs: a dsh with no launchable profile cannot start a
|
||||
* session. Callers gating the Run button want `isDeepSeekRunnable()`.
|
||||
*/
|
||||
export function isDeepSeekAvailable(): boolean {
|
||||
return resolveDeepSeekDir() !== null;
|
||||
}
|
||||
|
||||
/** Binary present AND at least one profile that can occupy a pane. */
|
||||
export function isDeepSeekRunnable(): boolean {
|
||||
return isDeepSeekAvailable() && resolveDefaultDeepSeekProfile() !== null;
|
||||
}
|
||||
|
||||
export function getDeepSeekNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(DEEPSEEK_NOT_FOUND, deepSeekResolver.diagnostics());
|
||||
}
|
||||
|
||||
/**
|
||||
* Version reported by the resolved `dsh` binary, or null when dsh is
|
||||
* unavailable. Surfaced through `GET /api/deepseek/status` so a misresolution
|
||||
* is diagnosable from the UI.
|
||||
*/
|
||||
export function getDeepSeekCliVersion(): string | null {
|
||||
return deepSeekResolver.resolve()?.metadata ?? null;
|
||||
}
|
||||
|
||||
/** Does the named profile exist and can it drive a pane? */
|
||||
export function profileExists(name: string): boolean {
|
||||
return existsSync(join(resolveDshHome(), 'profiles', name, 'package.json'));
|
||||
}
|
||||
@@ -46,5 +46,18 @@ export {
|
||||
} from './antigravity-cli-resolver.js';
|
||||
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
|
||||
export { resolveGrokDir, isGrokAvailable, getGrokCliVersion, getGrokNotFoundMessage } from './grok-cli-resolver.js';
|
||||
export {
|
||||
resolveDeepSeekDir,
|
||||
isDeepSeekAvailable,
|
||||
isDeepSeekRunnable,
|
||||
getDeepSeekCliVersion,
|
||||
getDeepSeekNotFoundMessage,
|
||||
listDeepSeekProfiles,
|
||||
resolveDefaultDeepSeekProfile,
|
||||
isLaunchableProfile,
|
||||
resolveDshHome,
|
||||
profileExists,
|
||||
} from './deepseek-cli-resolver.js';
|
||||
export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js';
|
||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||
export type { FileQueryMatcher } from './file-query.js';
|
||||
|
||||
+10
-5
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ const HOME_SESSIONS_MODE_BADGE = {
|
||||
antigravity: 'ag',
|
||||
pi: 'pi',
|
||||
grok: 'gk',
|
||||
deepseek: 'ds',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
@@ -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': '新建案例',
|
||||
|
||||
@@ -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…
|
||||
</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…
|
||||
</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>
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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']),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user