Files
Codeman/src/utils/deepseek-cli-resolver.ts
T
Codeman maintainer 4cda150493 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>
2026-08-24 03:37:56 +02:00

338 lines
14 KiB
TypeScript

/**
* @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'));
}