feat(sessions): a dsh session shows its route config's model while its screen names none

displayModel gains a `config` source, ranked below any report from the running
CLI and above the launch model: custom endpoint, then statusline or screen,
then config, then launch, then nothing. The screen still wins whenever it
names a model, since that is what the running TUI uses.

- Registry data: capabilities.modelDetect gains `configResolver`, a NAMED
  reader (src/model-config-resolvers.ts), like a launcher profile; dsh names
  'deepseek-route' (the reader from the previous commit). `screenLine` becomes
  optional; the schema refuses a modelDetect naming nothing, an unknown
  reader, or screenLines without a screenLine.
- Session: the reader runs from _withPaneLifecycle's finally, so at every pane
  start, attach and relaunch, with the session's own launch config
  (legacyConfigForMode) and env (its clamped overrides, then the server's), so
  a per-session DSH_HOME is the home read. Async; a read that lands after a
  newer one or after the session stopped is dropped; a remote or docker
  session reads nothing locally. A change emits displayModelChanged
  (broadcast and persist). Not restored after a restart: the next attach
  reads it again, and a restored screen value outranks it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 19:41:56 +02:00
parent 284f86b740
commit 661fe3dc13
9 changed files with 402 additions and 22 deletions
+14 -2
View File
@@ -15,7 +15,7 @@
import { z } from 'zod';
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { McpConfigFormat } from './types.js';
import type { McpConfigFormat, ModelConfigResolverName } from './types.js';
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z
@@ -382,12 +382,24 @@ const capabilitiesSchema = z
.refine(
(src) => compileVersionRegex(src) !== null && countCaptureGroups(src) === 1,
'screenLine must be a regex compileVersionRegex() accepts (at most 200 characters, no nested quantifiers) with exactly one capture group'
),
)
.optional(),
// Bounded hard, like watchingLines: every row it adds is one more row the agent
// itself may be able to write.
screenLines: z.number().int().min(1).max(4).optional(),
// A NAMED reader (src/model-config-resolvers.ts), never code in config.
configResolver: z.enum(['deepseek-route'] as const satisfies readonly ModelConfigResolverName[]).optional(),
})
.strict()
// Typos rather than configurations, refused at LOAD time like watchingLines.
.refine(
(v) => v.screenLine !== undefined || v.configResolver !== undefined,
'modelDetect declares nothing to read'
)
.refine(
(v) => v.screenLines === undefined || v.screenLine !== undefined,
'screenLines has nothing to bound without a screenLine'
)
.optional(),
privilegedParams: z
.array(
+3
View File
@@ -1243,6 +1243,9 @@ const DEEPSEEK: CliEntry = {
modelDetect: {
screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`,
screenLines: 3,
// With the status bar's model field off (or before it paints), the route the
// session's profile pins, read the way dsh-TUI resolves it: src/deepseek-route-config.ts.
configResolver: 'deepseek-route',
},
// Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the
// launcher's own default, `workspace-write`, which already asks. Clamping to
+11 -1
View File
@@ -93,6 +93,9 @@ export interface CliVariant {
/** The newline chord a CLI's composer reads as "insert a line break" (see `CliCapabilities.newline`). */
export type NewlineSequence = 'line-feed' | 'esc-enter';
/** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */
export type ModelConfigResolverName = 'deepseek-route';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
@@ -477,8 +480,15 @@ export interface CliCapabilities {
* own footer format), never on a shape the agent could print in its transcript. Measured
* on a live pane per CLI; absent means the CLI's screen is never read for a model and
* the session shows its launch model, if any.
*
* `configResolver` names a reader (src/model-config-resolvers.ts) that resolves the
* model the CLI's own config pins, the way that CLI resolves it for the session, for
* while the screen names none (its status line switched off, or not drawn yet). Read
* once per pane start, attach or relaunch, bounded and read-only; the screen still
* wins whenever it names a model. A NAMED reader, like a launcher profile, so the
* per-CLI behaviour stays data here and code in one module.
*/
modelDetect?: { screenLine: string; screenLines?: number };
modelDetect?: { screenLine?: string; screenLines?: number; configResolver?: ModelConfigResolverName };
/**
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
+49
View File
@@ -0,0 +1,49 @@
/**
* @fileoverview The config readers a CLI's registry entry may name for the model its
* session runs (`capabilities.modelDetect.configResolver`): the per-CLI behaviour lives
* here, keyed by name, so no code branches on a CLI id (like the launcher profiles in
* config/cli-registry/profiles.ts).
*
* A reader answers the model the CLI's own config pins for one session, or null when
* it pins none or the answer is in any doubt. It must be read-only, bounded (no
* synchronous filesystem call, nothing that can wait on a dead mount) and must return
* the model id alone, never another config value.
*
* @module model-config-resolvers
*/
import type { ModelConfigResolverName } from './config/cli-registry/types.js';
import { effectiveDshHome, readDeepSeekRouteModel } from './deepseek-route-config.js';
/** What a reader gets to know about the session. */
export interface ModelConfigContext {
/** The session's own launch config for its CLI (its `<Mode>Config`), if any. */
config: Record<string, unknown> | undefined;
/** The environment the session's CLI runs with (its own overrides, then the server's). */
env: (key: string) => string | undefined;
}
const RESOLVERS: Record<ModelConfigResolverName, (ctx: ModelConfigContext) => Promise<string | null>> = {
// dsh-TUI's route: the session's profile (else the one the launch boots, which the
// launch names from the server's own dsh home) read under the session's dsh home.
'deepseek-route': (ctx) =>
readDeepSeekRouteModel({
profile: ctx.config?.profile,
home: effectiveDshHome(ctx.env),
serverHome: effectiveDshHome((key) => process.env[key]),
}),
};
/**
* The model the named reader resolves for a session, or null.
*
* @param name a `configResolver` from the registry (schema-checked at load)
* @param ctx what the reader may know about the session
*/
export async function resolveConfigModel(
name: ModelConfigResolverName,
ctx: ModelConfigContext
): Promise<string | null> {
const resolver = RESOLVERS[name];
return resolver ? resolver(ctx) : null;
}
+9 -1
View File
@@ -13,7 +13,11 @@
* in-session `/model`); a CLI whose registry entry declares
* `capabilities.modelDetect` has its footer read off the pane capture the idle/working
* probe already takes.
* 3. **launch**: the model the session was launched with (claude's `--model` or the
* 3. **config**: the model the CLI's own config pins for this session, read by the
* reader its registry entry names (`capabilities.modelDetect.configResolver`, e.g. the
* dsh-TUI route: src/deepseek-route-config.ts), for while the screen names none. Not
* a report from the running CLI, so any report outranks it.
* 4. **launch**: the model the session was launched with (claude's `--model` or the
* app-wide default it was created with; another CLI's `<cli>Config.model`). What was
* asked for, not what was reported, so it only shows when nothing reported.
*
@@ -130,17 +134,21 @@ export function restoredReportedModel(saved: unknown): ReportedModel | undefined
*
* @param input.customModelId the custom endpoint's model, when the session is pointed at one
* @param input.reported the newest report from the CLI itself
* @param input.configModel the model the CLI's config pins for the session
* @param input.launchModel the model the session was launched with
*/
export function resolveDisplayModel(input: {
customModelId?: string;
reported?: ReportedModel | null;
configModel?: string | null;
launchModel?: string;
}): DisplayModel | undefined {
const custom = sanitizeModelName(input.customModelId);
if (custom) return { model: custom, source: 'custom-endpoint' };
const reported = input.reported ? sanitizeModelName(input.reported.model) : undefined;
if (reported && input.reported) return { model: reported, source: input.reported.source };
const config = sanitizeModelName(input.configModel);
if (config) return { model: config, source: 'config' };
const launch = sanitizeModelName(input.launchModel);
if (launch) return { model: launch, source: 'launch' };
return undefined;
+68 -16
View File
@@ -138,6 +138,8 @@ import {
upsertAttachmentHistory as upsertAttachmentHistoryList,
} from './session-attachment-history.js';
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js';
import { resolveConfigModel } from './model-config-resolvers.js';
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
import {
launchModelFor,
readScreenModel,
@@ -566,6 +568,14 @@ export class Session extends EventEmitter {
* restart, so an idle session keeps naming its model until the next report.
*/
private _reportedModel: ReportedModel | null = null;
/**
* The model the CLI's own config pins for this session (`modelDetect.configResolver`),
* read at each pane start, attach or relaunch; null when it pins none. Below any
* report from the running CLI in `displayModel`. Not persisted: the next start reads it.
*/
private _configModel: string | null = null;
/** Bumped per config read, so a read that lands after a newer one is dropped. */
private _configModelGen = 0;
/** Lazily compiled `capabilities.modelDetect.screenLine`. See _modelLinePattern(). */
private _modelLineRe: RegExp | null | undefined = undefined;
/** Resolved with the pattern above: how many rows at the foot of the screen it sees. */
@@ -1290,6 +1300,9 @@ export class Session extends EventEmitter {
} finally {
this._paneLifecycleOps--;
this._paneStartedAt = Date.now();
// A start, attach or relaunch is when the CLI read its config, so it is when
// the model that config pins is read here too.
this._refreshConfigModel();
}
}
@@ -3187,7 +3200,7 @@ export class Session extends EventEmitter {
private _modelLinePattern(): RegExp | null {
if (this._modelLineRe === undefined) {
const detect = getCli(this.mode)?.capabilities.modelDetect;
this._modelLineRe = detect ? compileVersionRegex(detect.screenLine) : null;
this._modelLineRe = detect?.screenLine ? compileVersionRegex(detect.screenLine) : null;
this._modelLineRows = detect?.screenLines ?? 1;
}
return this._modelLineRe;
@@ -3219,24 +3232,63 @@ export class Session extends EventEmitter {
return resolveDisplayModel({
customModelId: this._customModel?.modelId,
reported: this._reportedModel,
// The same option bag the spawn reads its launch params from: `model` at the top
// for claude (the `--model` or app-wide default it was created with; inert for
// every other CLI, which is why it is not handed over for them), each other CLI's
// own `<Mode>Config`. Where the model param lives is registry data.
launchModel: launchModelFor(this.mode, {
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
}),
configModel: this._configModel,
launchModel: launchModelFor(this.mode, this._launchOptionBag()),
});
}
/**
* The same option bag the spawn reads its launch params from: `model` at the top for
* claude (the `--model` or app-wide default it was created with; inert for every other
* CLI, which is why it is not handed over for them), each other CLI's own
* `<Mode>Config`. Where a param lives is registry data (`legacyConfigForMode`).
*/
private _launchOptionBag(): Record<string, unknown> {
return {
model: cliTakesSessionModel(this.mode) ? this._model : undefined,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
geminiConfig: this._geminiConfig,
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
};
}
/**
* Read the model this session's CLI config pins, with the reader its registry entry
* names (`capabilities.modelDetect.configResolver`), and announce a change. Async and
* bounded (the reader probes before it reads); a read that lands after a newer one,
* or after the session stopped, is dropped. A remote or docker session's CLI reads its
* config on another machine or in its container, so nothing local is read for it.
*/
private _refreshConfigModel(): void {
const name = getCli(this.mode)?.capabilities.modelDetect?.configResolver;
if (!name || this._remote || this._docker) return;
const gen = ++this._configModelGen;
const overrides = this._envOverrides;
resolveConfigModel(name, {
config: legacyConfigForMode(this.mode, this._launchOptionBag()),
// The session's own env first (already clamped for a non-granted owner), then the
// server's: what the pane's CLI inherits.
env: (key) => overrides?.[key] ?? process.env[key],
}).then(
(model) => {
if (gen !== this._configModelGen || this._isStopped) return;
// Sanitized where it is published (resolveDisplayModel), like every source.
const next = model || null;
if (next === this._configModel) return;
this._configModel = next;
this.emit('displayModelChanged');
},
() => {
/* A reader answers null on doubt and never throws; a throw changes nothing. */
}
);
}
/**
* Read the background-work chip off the same capture the working probe just took.
*
+5 -2
View File
@@ -646,9 +646,11 @@ export interface CustomModelSelection {
* - `custom-endpoint`: the Custom Model Endpoint Profile's model, which wins.
* - `statusline`: the CLI reported it (claude's statusLine exporter), follows a switch.
* - `screen`: read off the CLI's own footer (`capabilities.modelDetect`), follows a switch.
* - `config`: what the CLI's own config pins for this session
* (`capabilities.modelDetect.configResolver`), while its screen names none.
* - `launch`: what the session was launched with; nothing has reported since.
*/
export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'launch';
export type DisplayModelSource = 'custom-endpoint' | 'statusline' | 'screen' | 'config' | 'launch';
/** The model a session runs as far as the server knows, for a session header. */
export interface DisplayModel {
@@ -866,7 +868,8 @@ export interface SessionState {
/**
* The model this session runs, as far as the server knows it, and where that came from
* (src/session-display-model.ts): the custom endpoint's model, else the newest report
* from the CLI itself (statusline or its own footer), else the launch model. Absent when
* from the CLI itself (statusline or its own footer), else the model its config pins,
* else the launch model. Absent when
* none is known; a session header then shows the harness alone. Untrusted display text
* (pane-derived for `screen`): render it as text. Persisted, and a `statusline`/`screen`
* value is restored after a restart until the next report replaces it.