From 40428d3147f9cd7e38e30f43cd746afd7119b514 Mon Sep 17 00:00:00 2001 From: SmolSmol Date: Mon, 13 Jul 2026 02:40:59 +0800 Subject: [PATCH 1/4] feat(badge): show the model the session is actually using, not the config default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal model badge read the model only from the Claude settings cascade (settings files + env). But a user can switch models mid-session with /model, which never touches those files — so the badge could show "Fable" while the session was actually running Opus, with no indication anything had changed. (Hit in the wild: badge said Fable the whole time; /model showed Opus.) Read the live model from the running session's transcript instead. Claude Code records the model on every assistant turn in ~/.claude/projects//.jsonl, so the newest entry reflects the true current model even after a /model switch. resolveClaudeConfig now prefers that live model and falls back to the config-derived model when no transcript can be resolved. Effort still comes from config (it isn't in the transcript, and it hot-reloads from the settings file anyway). Implementation reads only the tail (64 KB) of the transcript so multi-MB files stay cheap, and skips synthetic placeholder models. Verified end-to-end against a real running session (returned claude-opus-4-8, matching /model, where the config default was claude-fable-5). Adds unit tests for the path encoding, tail model read (incl. skipping synthetic), live-over-config preference, and the no-transcript fallback. Full service suite green (23/23). Co-Authored-By: Claude Opus 4.8 --- server/agentModelConfigService.js | 82 ++++++++++++++++++++++ tests/unit/agentModelConfigService.test.js | 46 ++++++++++++ 2 files changed, 128 insertions(+) diff --git a/server/agentModelConfigService.js b/server/agentModelConfigService.js index 17386323..c3f2f15d 100644 --- a/server/agentModelConfigService.js +++ b/server/agentModelConfigService.js @@ -7,6 +7,10 @@ const path = require('path'); // within one request) without showing stale values for long. const FILE_CACHE_TTL_MS = 1500; +// The live model is read from the tail of the session transcript; 64 KB comfortably +// contains the most recent assistant turn even for very large (multi-MB) transcripts. +const TRANSCRIPT_TAIL_BYTES = 64 * 1024; + // Claude Code settings precedence (highest first) for the keys we care about: // CLI args > .claude/settings.local.json > .claude/settings.json > ~/.claude/settings.json // https://code.claude.com/docs/en/settings @@ -101,9 +105,87 @@ class AgentModelConfigService { } } + // Prefer the model the running session is ACTUALLY using, read from its Claude Code + // transcript. The user can switch models mid-session with /model, which never touches + // the settings files above — so the config-derived model can be stale/wrong (the + // "badge says Fable but I'm actually on Opus" trap). Fall back to config when no live + // transcript can be resolved. + const liveModel = this.resolveLiveClaudeModel(directory); + if (liveModel) { + resolved.model = liveModel; + resolved.modelSource = { label: 'live session (transcript)', file: null }; + } + return resolved; } + // Read the model the running Claude session is actually using, from its transcript + // (~/.claude/projects//.jsonl). Claude Code records the model on + // every assistant turn, so the newest one reflects a mid-session /model switch the + // settings files never see. Returns null (caller keeps the config model) when no + // transcript can be resolved. + resolveLiveClaudeModel(directory) { + try { + if (!this.isNonEmptyString(directory)) return null; + const cwd = path.resolve(directory); + const projectDir = path.join(this.homeDir, '.claude', 'projects', this.encodeClaudeProjectDir(cwd)); + let names; + try { + names = this.fs.readdirSync(projectDir); + } catch { + return null; + } + const newest = names + .filter((name) => name.endsWith('.jsonl')) + .map((name) => { + const file = path.join(projectDir, name); + let mtimeMs = 0; + try { mtimeMs = this.fs.statSync(file).mtimeMs; } catch { /* skip unreadable */ } + return { file, mtimeMs }; + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]; + if (!newest) return null; + return this.readLastModelFromTranscript(newest.file); + } catch { + return null; + } + } + + // Claude Code names each project's transcript folder by replacing every character in + // the absolute cwd that isn't a letter or digit with a dash. + encodeClaudeProjectDir(cwd) { + return String(cwd || '').replace(/[^a-zA-Z0-9]/g, '-'); + } + + // Return the most recent real model id from a transcript, reading only its tail so + // multi-MB files stay cheap. Skips synthetic placeholder models (e.g. ""). + readLastModelFromTranscript(file) { + let fd; + try { + fd = this.fs.openSync(file, 'r'); + const size = this.fs.fstatSync(fd).size; + const length = Math.min(size, TRANSCRIPT_TAIL_BYTES); + if (length <= 0) return null; + const buffer = Buffer.alloc(length); + this.fs.readSync(fd, buffer, 0, length, size - length); + const text = buffer.toString('utf8'); + const matches = text.match(/"model"\s*:\s*"([^"]+)"/g); + if (!matches) return null; + for (let i = matches.length - 1; i >= 0; i--) { + const parsed = matches[i].match(/"model"\s*:\s*"([^"]+)"/); + const model = parsed && parsed[1] ? parsed[1].trim() : ''; + if (model && !model.startsWith('<')) return model; + } + return null; + } catch { + return null; + } finally { + if (fd !== undefined) { + try { this.fs.closeSync(fd); } catch { /* ignore */ } + } + } + } + // Look an env var up the way a launched agent would see it: settings-layer // `env` blocks (local > project > user), then ~/.claude/.env (sourced by the // user's shell profile), then the server environment the PTY inherits. diff --git a/tests/unit/agentModelConfigService.test.js b/tests/unit/agentModelConfigService.test.js index 128817cb..b6d33e7f 100644 --- a/tests/unit/agentModelConfigService.test.js +++ b/tests/unit/agentModelConfigService.test.js @@ -255,4 +255,50 @@ describe('AgentModelConfigService', () => { expect(resolved.effortSource.label).toBe('server environment'); expect(resolved.effortSource.file).toBeNull(); }); + + const writeTranscript = (svc, cwd, jsonlLines) => { + const encoded = svc.encodeClaudeProjectDir(path.resolve(cwd)); + const dir = path.join(homeDir, '.claude', 'projects', encoded); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'session-abc.jsonl'); + fs.writeFileSync(file, jsonlLines.join('\n') + '\n'); + return file; + }; + + test('encodeClaudeProjectDir replaces non-alphanumeric chars with dashes', () => { + const svc = createService(); + expect(svc.encodeClaudeProjectDir('C:\\Users\\cuppy\\.agent-workspace\\work1')) + .toBe('C--Users-cuppy--agent-workspace-work1'); + }); + + test('reads the most recent real model from the transcript tail, skipping synthetic', () => { + const svc = createService(); + const file = writeTranscript(svc, worktreeDir, [ + JSON.stringify({ type: 'assistant', message: { model: 'claude-fable-5' } }), + JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-8' } }), + JSON.stringify({ type: 'assistant', message: { model: '' } }) + ]); + expect(svc.readLastModelFromTranscript(file)).toBe('claude-opus-4-8'); + }); + + test('resolveClaudeConfig prefers the live transcript model over the configured default', () => { + writeClaudeSettings(homeDir, 'settings.json', { model: 'claude-fable-5[1m]', effortLevel: 'high' }); + const svc = createService(); + writeTranscript(svc, worktreeDir, [ + JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-8' } }) + ]); + + const resolved = svc.resolveClaudeConfig(worktreeDir); + expect(resolved.model).toBe('claude-opus-4-8'); // live session wins + expect(resolved.modelSource.label).toBe('live session (transcript)'); + expect(resolved.effortLevel).toBe('high'); // effort still comes from config + }); + + test('resolveClaudeConfig keeps the configured model when there is no transcript', () => { + writeClaudeSettings(homeDir, 'settings.json', { model: 'claude-fable-5[1m]', effortLevel: 'high' }); + + const resolved = createService().resolveClaudeConfig(worktreeDir); + expect(resolved.model).toBe('claude-fable-5[1m]'); + expect(resolved.modelSource.label).toBe('user settings (global)'); + }); }); From 2723e1461002940d3711249eddd3443db37945fc Mon Sep 17 00:00:00 2001 From: web3dev1337 Date: Sat, 18 Jul 2026 11:53:12 +1000 Subject: [PATCH 2/4] fix: parse transcript JSON lines for the live model instead of substring regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings addressed: - The regex tail-scan matched any "model":"..." substring, including model params inside message content (sub-agent spawns, quoted JSON) — verified against a real transcript where it would report a sub-agent's model. Now whole JSONL lines are parsed newest-first and only an assistant turn's top-level message.model counts. - Live-model lookups are now cached per directory with the same TTL as the file cache, so per-session badge polling doesn't re-scan the project dir and stat every historical transcript each time. - The cwd->project-folder encoder is hoisted to utils/pathUtils and shared with sessionRecoveryService instead of being a fourth private copy. - The model badge tooltip no longer claims session-only /model picks can't be shown — transcript detection is exactly what surfaces them. Co-Authored-By: Claude Fable 5 --- client/app.js | 2 +- server/agentModelConfigService.js | 81 ++++++++++++++-------- server/sessionRecoveryService.js | 14 ++-- server/utils/pathUtils.js | 12 ++++ tests/unit/agentModelConfigService.test.js | 35 ++++++++++ 5 files changed, 105 insertions(+), 39 deletions(-) diff --git a/client/app.js b/client/app.js index b80f76fc..f63e8bfa 100644 --- a/client/app.js +++ b/client/app.js @@ -804,7 +804,7 @@ class ClaudeOrchestrator { if (effortLevel) { tooltipLines.push(`Effort: ${effortLevel} — from ${describeSource(config.effortSource)}`); } - tooltipLines.push('Note: a /model pick for "this session only" is not written to disk and won\'t show here.'); + tooltipLines.push('Note: when a live session transcript is found, Model reflects what the session is actually using (including "/model" switches); otherwise it shows the configured default.'); return { text, tooltip: tooltipLines.join('\n'), effortLevel }; } diff --git a/server/agentModelConfigService.js b/server/agentModelConfigService.js index c3f2f15d..0e63db00 100644 --- a/server/agentModelConfigService.js +++ b/server/agentModelConfigService.js @@ -1,6 +1,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +const { claudeProjectFolderName } = require('./utils/pathUtils'); // Claude Code re-reads these files on every launch, so a short cache keeps // badge refreshes cheap (and dedupes the shared user-global file across sessions @@ -41,6 +42,7 @@ class AgentModelConfigService { this.fs = fsImpl; this.processEnv = processEnv; this.fileCache = new Map(); + this.liveModelCache = new Map(); } static getInstance(options = {}) { @@ -123,42 +125,58 @@ class AgentModelConfigService { // (~/.claude/projects//.jsonl). Claude Code records the model on // every assistant turn, so the newest one reflects a mid-session /model switch the // settings files never see. Returns null (caller keeps the config model) when no - // transcript can be resolved. + // transcript can be resolved. Results are cached per directory with the same TTL as + // file reads — the client polls this endpoint per session, and each uncached lookup + // costs a readdir + a stat per historical transcript. resolveLiveClaudeModel(directory) { try { if (!this.isNonEmptyString(directory)) return null; const cwd = path.resolve(directory); - const projectDir = path.join(this.homeDir, '.claude', 'projects', this.encodeClaudeProjectDir(cwd)); - let names; - try { - names = this.fs.readdirSync(projectDir); - } catch { - return null; + const now = Date.now(); + const cached = this.liveModelCache.get(cwd); + if (cached && now - cached.readAt < FILE_CACHE_TTL_MS) { + return cached.model; } - const newest = names - .filter((name) => name.endsWith('.jsonl')) - .map((name) => { - const file = path.join(projectDir, name); - let mtimeMs = 0; - try { mtimeMs = this.fs.statSync(file).mtimeMs; } catch { /* skip unreadable */ } - return { file, mtimeMs }; - }) - .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]; - if (!newest) return null; - return this.readLastModelFromTranscript(newest.file); + const model = this.readLiveClaudeModel(cwd); + this.liveModelCache.set(cwd, { readAt: now, model }); + return model; } catch { return null; } } - // Claude Code names each project's transcript folder by replacing every character in - // the absolute cwd that isn't a letter or digit with a dash. + readLiveClaudeModel(cwd) { + const projectDir = path.join(this.homeDir, '.claude', 'projects', this.encodeClaudeProjectDir(cwd)); + let names; + try { + names = this.fs.readdirSync(projectDir); + } catch { + return null; + } + const newest = names + .filter((name) => name.endsWith('.jsonl')) + .map((name) => { + const file = path.join(projectDir, name); + let mtimeMs = 0; + try { mtimeMs = this.fs.statSync(file).mtimeMs; } catch { /* skip unreadable */ } + return { file, mtimeMs }; + }) + .sort((a, b) => b.mtimeMs - a.mtimeMs)[0]; + if (!newest) return null; + return this.readLastModelFromTranscript(newest.file); + } + + // Claude Code names each project's transcript folder by sanitizing the absolute cwd; + // delegates to the shared implementation used by session recovery. encodeClaudeProjectDir(cwd) { - return String(cwd || '').replace(/[^a-zA-Z0-9]/g, '-'); + return claudeProjectFolderName(cwd); } // Return the most recent real model id from a transcript, reading only its tail so - // multi-MB files stay cheap. Skips synthetic placeholder models (e.g. ""). + // multi-MB files stay cheap. Parses whole JSONL lines and only accepts the top-level + // message.model of assistant turns — a substring scan would also match "model" keys + // inside message CONTENT (tool params, quoted JSON in code discussions) and show a + // wrong-but-authoritative-looking model. Skips synthetic placeholders (""). readLastModelFromTranscript(file) { let fd; try { @@ -168,12 +186,19 @@ class AgentModelConfigService { if (length <= 0) return null; const buffer = Buffer.alloc(length); this.fs.readSync(fd, buffer, 0, length, size - length); - const text = buffer.toString('utf8'); - const matches = text.match(/"model"\s*:\s*"([^"]+)"/g); - if (!matches) return null; - for (let i = matches.length - 1; i >= 0; i--) { - const parsed = matches[i].match(/"model"\s*:\s*"([^"]+)"/); - const model = parsed && parsed[1] ? parsed[1].trim() : ''; + const lines = buffer.toString('utf8').split('\n'); + // Walk newest-first; a truncated first line of the tail simply fails to parse. + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line) continue; + let parsed; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (!parsed || parsed.type !== 'assistant') continue; + const model = typeof parsed.message?.model === 'string' ? parsed.message.model.trim() : ''; if (model && !model.startsWith('<')) return model; } return null; diff --git a/server/sessionRecoveryService.js b/server/sessionRecoveryService.js index cd97c816..6fb0bc55 100644 --- a/server/sessionRecoveryService.js +++ b/server/sessionRecoveryService.js @@ -9,7 +9,7 @@ const fs = require('fs').promises; const os = require('os'); const path = require('path'); const winston = require('winston'); -const { getAgentWorkspaceDir } = require('./utils/pathUtils'); +const { getAgentWorkspaceDir, claudeProjectFolderName } = require('./utils/pathUtils'); const HOME_DIR = process.env.HOME || os.homedir(); const RECOVERY_DIR = path.join(getAgentWorkspaceDir(), 'session-recovery'); @@ -98,16 +98,10 @@ class SessionRecoveryService { return path.join(RECOVERY_DIR, `${safeId}.json`); } - /** - * Claude Code stores conversations under ~/.claude/projects/, - * sanitizing EVERY character outside [a-zA-Z0-9-] to '-' - * ('C:\Users\x\.app' -> 'C--Users-x--app', '/home/x/.app' -> '-home-x--app'). - * Replacing only slashes leaves ':' and '.' behind, so conversation lookups - * never matched and recovery silently downgraded "resume conversation" to - * "start fresh". - */ + // Delegates to the shared implementation in utils/pathUtils (see the scar-tissue + // comment there: replacing only slashes silently broke conversation lookups once). claudeProjectFolderName(targetPath) { - return String(targetPath || '').replace(/[^a-zA-Z0-9-]/g, '-'); + return claudeProjectFolderName(targetPath); } /** diff --git a/server/utils/pathUtils.js b/server/utils/pathUtils.js index e70e381d..14a37dbd 100644 --- a/server/utils/pathUtils.js +++ b/server/utils/pathUtils.js @@ -484,11 +484,23 @@ function getTrailingPathLabel(value, count = 2) { return splitPathSegments(value).slice(-safeCount).join('/'); } +/** + * Claude Code stores conversations under ~/.claude/projects/, + * sanitizing EVERY character outside [a-zA-Z0-9-] to '-' + * ('C:\Users\x\.app' -> 'C--Users-x--app', '/home/x/.app' -> '-home-x--app'). + * Replacing only slashes leaves ':' and '.' behind, so lookups silently miss. + * Single shared implementation — this exact regex has been gotten wrong before. + */ +function claudeProjectFolderName(targetPath) { + return String(targetPath || '').replace(/[^a-zA-Z0-9-]/g, '-'); +} + module.exports = { normalizePathSlashes, splitPathSegments, getPathBasename, getTrailingPathLabel, + claudeProjectFolderName, getAgentWorkspaceDir, getDefaultAgentWorkspaceDir, getLegacyAgentWorkspaceDir, diff --git a/tests/unit/agentModelConfigService.test.js b/tests/unit/agentModelConfigService.test.js index b6d33e7f..ed0dcfe0 100644 --- a/tests/unit/agentModelConfigService.test.js +++ b/tests/unit/agentModelConfigService.test.js @@ -301,4 +301,39 @@ describe('AgentModelConfigService', () => { expect(resolved.model).toBe('claude-fable-5[1m]'); expect(resolved.modelSource.label).toBe('user settings (global)'); }); + + test('ignores "model" strings inside message content — only assistant message.model counts', () => { + const svc = createService(); + const file = writeTranscript(svc, worktreeDir, [ + // Real per-turn model, followed in the SAME line by a sub-agent spawn param and + // quoted JSON in the assistant's text — both must not win over message.model. + JSON.stringify({ + type: 'assistant', + message: { + model: 'claude-fable-5', + content: [ + { type: 'text', text: 'set {"model": "gpt-4"} in your config' }, + { type: 'tool_use', name: 'Agent', input: { model: 'sonnet', prompt: 'scout' } } + ] + } + }), + // Non-assistant lines with model-shaped strings must be skipped entirely. + JSON.stringify({ type: 'user', message: { content: 'try "model": "haiku" maybe?' } }) + ]); + expect(svc.readLastModelFromTranscript(file)).toBe('claude-fable-5'); + }); + + test('caches the live-model lookup so per-session polling stays cheap', () => { + writeClaudeSettings(homeDir, 'settings.json', { model: 'claude-fable-5[1m]', effortLevel: 'high' }); + const svc = createService(); + const file = writeTranscript(svc, worktreeDir, [ + JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-8' } }) + ]); + + expect(svc.resolveClaudeConfig(worktreeDir).model).toBe('claude-opus-4-8'); + + // Within the cache TTL the transcript is not re-read, even if it changed on disk. + fs.writeFileSync(file, JSON.stringify({ type: 'assistant', message: { model: 'claude-sonnet-5' } }) + '\n'); + expect(svc.resolveClaudeConfig(worktreeDir).model).toBe('claude-opus-4-8'); + }); }); From f26df7e7b986b7d812380792cfe8656c3fea58b5 Mon Sep 17 00:00:00 2001 From: web3dev1337 Date: Sat, 18 Jul 2026 16:02:12 +1000 Subject: [PATCH 3/4] fix: only trust the transcript model while a Claude agent is actually running User feedback: terminals with no open chat still showed model/effort from the worktree's most recent transcript, presented as live state. A finished or closed session leaves its transcript behind, so transcript detection is now gated on session recovery reporting an active Claude agent for that terminal; otherwise the badge shows the configured launch default. Co-Authored-By: Claude Fable 5 --- server/agentModelConfigService.js | 18 +++++++++++------- server/index.js | 7 ++++++- tests/unit/agentModelConfigService.test.js | 22 ++++++++++++++++++---- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/server/agentModelConfigService.js b/server/agentModelConfigService.js index 0e63db00..90f832b1 100644 --- a/server/agentModelConfigService.js +++ b/server/agentModelConfigService.js @@ -52,7 +52,7 @@ class AgentModelConfigService { return AgentModelConfigService.instance; } - resolveClaudeConfig(directory) { + resolveClaudeConfig(directory, { agentRunning = false } = {}) { const resolved = { agent: 'claude', model: null, @@ -110,12 +110,16 @@ class AgentModelConfigService { // Prefer the model the running session is ACTUALLY using, read from its Claude Code // transcript. The user can switch models mid-session with /model, which never touches // the settings files above — so the config-derived model can be stale/wrong (the - // "badge says Fable but I'm actually on Opus" trap). Fall back to config when no live - // transcript can be resolved. - const liveModel = this.resolveLiveClaudeModel(directory); - if (liveModel) { - resolved.model = liveModel; - resolved.modelSource = { label: 'live session (transcript)', file: null }; + // "badge says Fable but I'm actually on Opus" trap). Only consulted while a Claude + // agent is actually running in the terminal: a closed/finished chat leaves its + // transcript behind, and showing that as if it were live state is the same trap + // in the other direction. Falls back to config when no transcript resolves. + if (agentRunning) { + const liveModel = this.resolveLiveClaudeModel(directory); + if (liveModel) { + resolved.model = liveModel; + resolved.modelSource = { label: 'live session (transcript)', file: null }; + } } return resolved; diff --git a/server/index.js b/server/index.js index 347b7a62..84ca3682 100644 --- a/server/index.js +++ b/server/index.js @@ -2843,9 +2843,14 @@ app.get('/api/sessions/model-config', (req, res) => { const type = String(session?.type || '').trim().toLowerCase(); if (type !== 'claude' && type !== 'codex') continue; const cwd = sessionManager.getSessionCwd(session); + // Live transcript detection only applies while a Claude agent is actually + // running in this terminal; otherwise the badge shows the launch config. + const workspaceId = String(session?.workspace || '').trim(); + const recovery = workspaceId ? sessionRecoveryService.getSession(workspaceId, sessionId) : null; + const claudeAgentRunning = recovery?.lastAgent === 'claude' && recovery?.lastAgentActive !== false; sessions[sessionId] = { cwd, - claude: agentModelConfigService.resolveClaudeConfig(cwd) + claude: agentModelConfigService.resolveClaudeConfig(cwd, { agentRunning: claudeAgentRunning }) }; } return res.json({ diff --git a/tests/unit/agentModelConfigService.test.js b/tests/unit/agentModelConfigService.test.js index ed0dcfe0..902fa444 100644 --- a/tests/unit/agentModelConfigService.test.js +++ b/tests/unit/agentModelConfigService.test.js @@ -281,19 +281,33 @@ describe('AgentModelConfigService', () => { expect(svc.readLastModelFromTranscript(file)).toBe('claude-opus-4-8'); }); - test('resolveClaudeConfig prefers the live transcript model over the configured default', () => { + test('resolveClaudeConfig prefers the live transcript model while an agent is running', () => { writeClaudeSettings(homeDir, 'settings.json', { model: 'claude-fable-5[1m]', effortLevel: 'high' }); const svc = createService(); writeTranscript(svc, worktreeDir, [ JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-8' } }) ]); - const resolved = svc.resolveClaudeConfig(worktreeDir); + const resolved = svc.resolveClaudeConfig(worktreeDir, { agentRunning: true }); expect(resolved.model).toBe('claude-opus-4-8'); // live session wins expect(resolved.modelSource.label).toBe('live session (transcript)'); expect(resolved.effortLevel).toBe('high'); // effort still comes from config }); + test('resolveClaudeConfig ignores leftover transcripts when no agent is running', () => { + writeClaudeSettings(homeDir, 'settings.json', { model: 'claude-fable-5[1m]', effortLevel: 'high' }); + const svc = createService(); + // A finished/closed chat leaves its transcript behind — with no agent + // running, the badge must show the configured launch default instead. + writeTranscript(svc, worktreeDir, [ + JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-8' } }) + ]); + + const resolved = svc.resolveClaudeConfig(worktreeDir); + expect(resolved.model).toBe('claude-fable-5[1m]'); + expect(resolved.modelSource.label).toBe('user settings (global)'); + }); + test('resolveClaudeConfig keeps the configured model when there is no transcript', () => { writeClaudeSettings(homeDir, 'settings.json', { model: 'claude-fable-5[1m]', effortLevel: 'high' }); @@ -330,10 +344,10 @@ describe('AgentModelConfigService', () => { JSON.stringify({ type: 'assistant', message: { model: 'claude-opus-4-8' } }) ]); - expect(svc.resolveClaudeConfig(worktreeDir).model).toBe('claude-opus-4-8'); + expect(svc.resolveClaudeConfig(worktreeDir, { agentRunning: true }).model).toBe('claude-opus-4-8'); // Within the cache TTL the transcript is not re-read, even if it changed on disk. fs.writeFileSync(file, JSON.stringify({ type: 'assistant', message: { model: 'claude-sonnet-5' } }) + '\n'); - expect(svc.resolveClaudeConfig(worktreeDir).model).toBe('claude-opus-4-8'); + expect(svc.resolveClaudeConfig(worktreeDir, { agentRunning: true }).model).toBe('claude-opus-4-8'); }); }); From 40ba0040c3e99a32b89678c5d0bd48d4e164dd7a Mon Sep 17 00:00:00 2001 From: web3dev1337 Date: Sat, 18 Jul 2026 16:02:12 +1000 Subject: [PATCH 4/4] style: model name first in the badge; mute the high-effort orange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: with xhigh as the daily default the warning-orange chip lit up every terminal header — now expensive efforts keep only a faintly tinted border. Badge text order flips to model · EFFORT: the model is the fact you scan for, effort is the qualifier. Co-Authored-By: Claude Fable 5 --- client/app.js | 6 +++--- client/styles.css | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/app.js b/client/app.js index f63e8bfa..5ad3c17c 100644 --- a/client/app.js +++ b/client/app.js @@ -790,8 +790,8 @@ class ClaudeOrchestrator { const modelLabel = String(config.model || '').replace(/^claude-/i, ''); const effortLevel = String(config.effortLevel || '').trim().toLowerCase(); - // Effort first: it stays readable even when a narrow header truncates the chip. - const text = [effortLevel.toUpperCase(), modelLabel].filter(Boolean).join(' · '); + // Model first — it's the fact you scan for; effort is the qualifier. + const text = [modelLabel, effortLevel.toUpperCase()].filter(Boolean).join(' · '); const tooltipLines = ['Model & effort agent launches in this worktree will use (settings files + env overrides).']; const describeSource = (source) => { @@ -804,7 +804,7 @@ class ClaudeOrchestrator { if (effortLevel) { tooltipLines.push(`Effort: ${effortLevel} — from ${describeSource(config.effortSource)}`); } - tooltipLines.push('Note: when a live session transcript is found, Model reflects what the session is actually using (including "/model" switches); otherwise it shows the configured default.'); + tooltipLines.push('Note: while a Claude session is running here, Model reflects what it is actually using (including "/model" switches); otherwise the configured launch default is shown.'); return { text, tooltip: tooltipLines.join('\n'), effortLevel }; } diff --git a/client/styles.css b/client/styles.css index 34dc342f..a043a370 100644 --- a/client/styles.css +++ b/client/styles.css @@ -1796,15 +1796,15 @@ header { cursor: help; } -/* Expensive effort levels stand out so token burn is visible at a glance. */ +/* Expensive effort levels get only a faintly tinted border — a whisper, not a + shout. With xhigh as a daily-driver default, the old bright orange chip lit + up every terminal header and became pure noise. */ .terminal-model-badge[data-effort="xhigh"], .terminal-model-badge[data-effort="ultra"], .terminal-model-badge[data-effort="ultra-code"], .terminal-model-badge[data-effort="ultracode"], .terminal-model-badge[data-effort="max"] { - color: var(--accent-warning); - border-color: color-mix(in srgb, var(--accent-warning) 45%, transparent); - background: color-mix(in srgb, var(--accent-warning) 10%, transparent); + border-color: color-mix(in srgb, var(--accent-warning) 28%, transparent); } .terminal-model-badge[data-effort="low"], @@ -1814,8 +1814,8 @@ header { background: color-mix(in srgb, var(--accent-success) 8%, transparent); } -/* Narrow viewports: keep the (effort-first) chip from crowding out the - terminal title/branch in the header flex row. */ +/* Narrow viewports: keep the chip from crowding out the terminal + title/branch in the header flex row. */ @media (max-width: 640px) { .terminal-model-badge { max-width: 6.5rem;