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
@@ -7,6 +7,9 @@
* the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event`
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
*
* Also records the session's model (`model.display_name`) as its reported model, which
* `session:updated` publishes as `displayModel` for the session headers.
*
* Returns a compact plain-text status string for the exporter to print as the
* in-terminal footer (print-through) when it has no statusline of the user's
* own to wrap. An unknown session gets an EMPTY body: the old brand-word
@@ -26,6 +29,7 @@ import {
import { SessionStatusTelemetry } from '../sse-events.js';
import { setLatestPlanUsage } from '../plan-usage-latest.js';
import type { SessionPort, EventPort } from '../ports/index.js';
import { getCli } from '../../config/cli-registry/index.js';
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
// Last broadcast telemetry signature per session — the statusline fires on
@@ -46,6 +50,15 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
const payload = data as RawStatuslinePayload | undefined;
// The model the CLI says it is running, for the session headers (displayModel). It
// rides every render, so it follows an in-session `/model`; the session dedupes and
// broadcasts only a change. Only a CLI that has a statusline exporter at all may
// report one here.
const session = ctx.sessions.get(sessionId);
if (session && getCli(session.mode)?.capabilities.statusLineTelemetry) {
session.noteReportedModel('statusline', payload?.model?.display_name);
}
// Plan-usage limits (account-wide) → broadcast to the header chip, when
// present and changed (the statusline fires on every assistant message).
const telemetry = parseStatusTelemetry(payload);
+4
View File
@@ -3542,6 +3542,10 @@ export class WebServer extends EventEmitter {
// pane-exit watcher's own tick replaces it with a first-hand
// reading (not the stats collector — see `startPaneExitWatcher`).
paneExit: savedState?.paneExit,
// The model the CLI last reported (statusline or its own footer), so a
// tile header keeps naming it until the next report; an idle claude
// session re-renders its statusline only on its next turn.
displayModel: savedState?.displayModel,
// A record rebuilt from the socket has no provenance, so its
// apparent locality is a guess (see `MuxSession.discovered`).
discoveredMuxSession: muxSession.discovered,
+13
View File
@@ -44,6 +44,7 @@ export interface SessionListenerRefs {
working: () => void;
idle: () => void;
watchingChanged: () => void;
displayModelChanged: () => void;
taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void;
taskCompleted: (task: BackgroundTask) => void;
@@ -275,6 +276,16 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
deps.broadcastSessionStateDebounced(session.id);
},
/**
* Pushes the session state when the model the CLI reports changes (its statusline,
* or its own footer), which the status does not reflect. Persisted too: a restart
* restores the last reported model until the next report.
*/
displayModelChanged: () => {
deps.broadcastSessionStateDebounced(session.id);
deps.persistSessionState(session);
},
// ─── Background Task Events ──────────────────────────────
/** Broadcasts `task:created` — new background task discovered */
@@ -508,6 +519,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('working', refs.working);
session.on('idle', refs.idle);
session.on('watchingChanged', refs.watchingChanged);
session.on('displayModelChanged', refs.displayModelChanged);
session.on('taskCreated', refs.taskCreated);
session.on('taskUpdated', refs.taskUpdated);
session.on('taskCompleted', refs.taskCompleted);
@@ -545,6 +557,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('working', refs.working);
session.off('idle', refs.idle);
session.off('watchingChanged', refs.watchingChanged);
session.off('displayModelChanged', refs.displayModelChanged);
session.off('taskCreated', refs.taskCreated);
session.off('taskUpdated', refs.taskUpdated);
session.off('taskCompleted', refs.taskCompleted);