feat(sessions): publish the model each session runs (displayModel)

A session header can only name the model a session runs if the server knows
it, so SessionState gains `displayModel: { model, source }`, resolved in a pure
module (src/session-display-model.ts), strongest first:

- custom-endpoint: a Custom Model Endpoint Profile's modelId answers the
  session, whatever alias the CLI prints;
- statusline / screen: the newest report from the running CLI itself.
  Claude's statusLine exporter already posts model.display_name on every
  render; the status-telemetry route now records it (only for a CLI with
  capabilities.statusLineTelemetry). A CLI whose registry entry declares the
  new capabilities.modelDetect has its footer read off the pane capture the
  idle/working probe already takes (no extra tmux call), so an in-session
  /model switch is followed at the next transition;
- launch: the model the session was launched with (claude's --model or the
  app-wide default, another CLI's <cli>Config.model), read where the registry
  says the model param lives;
- nothing known: no field, never a placeholder.

modelDetect is registry data, measured on live panes: dsh-TUI's status line
on the row under its composer (qwen3.8-27b on the owner's route) and codex's
`<model> <effort> ·` footer on its last row. Both anchor on chrome only that
CLI draws, over the last rows of the screen only; a transcript line shaped like
the footer is never taken (fixture tests). The pattern goes through
compileVersionRegex() with exactly one capture group, checked at load time.

An unreadable or covered footer keeps the last model (unlike the watching
label: a model does not stop running when something covers its row). Model
text is untrusted: escape sequences and control characters are stripped and it
is capped at 64 characters. A change emits displayModelChanged, broadcast
(session:updated) and persisted; a restart restores a CLI-reported model until
the next report.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-07 13:31:59 +02:00
parent d044406f8f
commit 8392854619
14 changed files with 794 additions and 5 deletions
+13
View File
@@ -119,3 +119,16 @@ export function compileVersionRegex(source: string): RegExp | null {
return null;
}
}
/**
* How many capture groups a regex source declares (named ones included), or -1 when it
* does not compile. Matching the empty string against `source|` always succeeds through
* the empty alternative, and the match array then has one slot per group.
*/
export function countCaptureGroups(source: string): number {
try {
return (new RegExp(`${source}|`).exec('') as RegExpExecArray).length - 1;
} catch {
return -1;
}
}
+20 -1
View File
@@ -13,7 +13,7 @@
*/
import { z } from 'zod';
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { McpConfigFormat } from './types.js';
@@ -370,6 +370,25 @@ const capabilitiesSchema = z
model: z
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
.strict(),
// Same guard as the workDetect patterns: ~/.codeman/clis.json can set it, and it runs
// over the foot of a pane capture every time a session settles. Exactly one capture
// group (the model), checked here so a pattern without one fails at LOAD time instead
// of silently never naming a model.
modelDetect: z
.object({
screenLine: z
.string()
.min(1)
.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'
),
// 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(),
})
.strict()
.optional(),
privilegedParams: z
.array(
z
+22
View File
@@ -622,6 +622,16 @@ const CODEX: CliEntry = {
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
watchingLines: 3,
},
// The footer under the composer, measured on a live 0.147.0 pane:
// ` gpt-5.6-terra default · ~/codeman-cases/th-scratch` (model, reasoning effort,
// cwd). It is the pane's LAST row, below the composer, so the transcript never
// reaches it, and the effort word right after the model is codex's own format: an
// open slash-command popup or a bare line of prose does not have that shape. A
// footer without an effort word (a model with no reasoning setting) is not read,
// and the session keeps its last known or launch model.
modelDetect: {
screenLine: String.raw`^ {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · `,
},
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
@@ -1222,6 +1232,18 @@ const DEEPSEEK: CliEntry = {
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Model is NOT a session field for dsh — it is a profile composition entry.
model: { source: 'none' },
// So the screen is where the model is known: dsh-TUI resolves the route itself
// (profile cordis.yml pin, else the persisted `/model` choice, else its default;
// lib/types/modelRoute.js) and its status line draws "the route requests actually
// take", model first (StatusLine.js; `statusBar.model` is on by default and forced
// on in minimal mode). Measured on dsh-TUI 0.10.0-beta.1: the composer's rounded box
// and, on the row right under its bottom border, ` qwen3.8-27b · medium · <cwd>`.
// The border anchors it: nothing the agent writes can sit below the composer, and a
// suggestion popup there starts with `/` or `+`, never a model id.
modelDetect: {
screenLine: String.raw`╰─+╯\n ?([A-Za-z0-9][\w.:/@+-]{0,79})(?= · |\n|$)`,
screenLines: 3,
},
// Only-if-sent, like codex/antigravity/grok: an ABSENT permissionMode means the
// launcher's own default, `workspace-write`, which already asks. Clamping to
// `read-only` instead would break the workspace rather than protect it.
+17
View File
@@ -462,6 +462,23 @@ export interface CliCapabilities {
statusLineTelemetry: boolean;
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
/**
* Where this CLI draws the model it is running, so a session header can name it
* (`SessionState.displayModel`, src/session-display-model.ts).
*
* `screenLine` is the source of a regex with exactly ONE capture group, the model. It
* runs over the last `screenLines` non-blank rows of the pane capture the idle/working
* probe already takes (rows joined with `\n`, so a pattern may span them), which costs no
* extra tmux call and re-reads the footer at every turn transition, so an in-session
* `/model` switch is followed.
*
* ⚠ The rows are pane text and the agent writes most of a pane, so a pattern must anchor
* on chrome only this CLI draws (the row under its own composer, an effort word in its
* 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.
*/
modelDetect?: { screenLine: string; screenLines?: number };
/**
* 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.