feat(deepseek): read the model a dsh session's TUI route config pins

A DeepSeek session whose screen names no model (dsh-TUI's status bar model
field off, or not drawn yet) can still name the model its route config pins
(owner request). src/deepseek-route-config.ts resolves it the way dsh and
dsh-TUI 0.10.0-beta.1 do, for the session's profile (else the one the launch
boots) under the session's dsh home:

- dsh composes a profile from patch layers: the bundles, then
  profiles/<profile>/cordis.patch.yml, then $DSH_HOME/cordis.patch.yml (which
  outranks it). A patch's `config` replaces the dsh-tui row's whole config, a
  `name` mismatch skips it, `disabled` turns the row off.
- dsh-TUI takes its route from that config only when it names BOTH provider and
  model (modelRoute.js); anything less falls back to state the config does not
  hold, so the answer is nothing. The bundle row pins a provider alone by
  design and is not read (it resolves outside the dsh home); settings.yaml's
  agent-default-model is the headless default and is never read.
- Any doubt answers nothing: a profile without dsh-TUI, a half-pinned route,
  an unreadable or oversized layer, a symlink out of the dsh home, a mount that
  does not answer, a file beyond a narrow strict YAML subset (no dependency
  added: plain keys, single-line string scalars for the values it needs; tags,
  anchors, aliases, merge keys, multi-line scalars, flow or block-scalar
  config, duplicate keys, typed scalars, a second document, or a nested row
  re-defining dsh-tui all answer null).
- Bounded and read-only: every path is probed with probePathKind() first, read
  async with a 64 KiB cap, and must realpath inside the dsh home. Only the
  model id leaves the module.

The default-profile inventory reuses the resolver's classification through a
new pure deepSeekProfileFromManifest(), read with the same bounded rules.
Not wired to sessions yet; the next commit does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 19:27:45 +02:00
parent c481bf4f47
commit 284f86b740
3 changed files with 758 additions and 2 deletions
+19 -2
View File
@@ -156,6 +156,11 @@ const STOCK_NON_INTERACTIVE_PROFILES = new Map<string, DeepSeekProfileKind>([
/** Profile directory names that are not profiles. */
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
/** Whether a directory under `$DSH_HOME/profiles` can be a profile at all (not `node_modules`, not hidden). */
export function isProfileDirName(name: string): boolean {
return !NON_PROFILE_DIRS.has(name) && !name.startsWith('.');
}
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
@@ -174,7 +179,19 @@ function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
*/
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
try {
const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8');
return deepSeekProfileFromManifest(name, readFileSync(join(profilesDir, name, 'package.json'), 'utf-8'));
} catch {
return null;
}
}
/**
* A profile from its directory name and the text of its `package.json`, or null when
* that text is not JSON. Pure, so a caller with its own (bounded, async) reads gets the
* same classification as the inventory below.
*/
export function deepSeekProfileFromManifest(name: string, raw: string): DeepSeekProfile | null {
try {
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') : [];
@@ -198,7 +215,7 @@ export function listDeepSeekProfiles(): DeepSeekProfile[] {
let entries: string[];
try {
entries = readdirSync(profilesDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.'))
.filter((e) => e.isDirectory() && isProfileDirName(e.name))
.map((e) => e.name);
} catch {
return [];